Module 2
Mastering IoC, Spring Framework & Testing
This work is licensed under CC BY-NC-SA 4.0
© Way-Up 2025
Dependency Injection (DI) has been proposed to improve the following situations:
Goal: Decouple object creation from object usage
public class UserService {
private UserRepository repository;
public UserService() {
// Tight coupling - hard to test, hard to change
this.repository = new MySQLUserRepository();
}
public User findUser(Long id) {
return repository.findById(id);
}
}
Issues:
public class UserService {
private UserRepository repository; // (#1:Repository interface - not a concrete implementation)
// (#2:Dependencies are injected from outside via constructor)
public UserService(UserRepository repository) {
this.repository = repository;
}
public User findUser(Long id) {
return repository.findById(id); // (#3:Works with any UserRepository implementation)
}
}
Benefits:
The principle of Dependency Injection relies on:
public UserService(UserRepository repository) {
this.repository = repository;
}
public void setRepository(UserRepository repository) {
this.repository = repository;
}
@Autowired
private UserRepository repository;
To implement DI, several frameworks exist:
Spring is among the most used, this is why our examples will focus on Spring
Spring is a comprehensive framework for enterprise Java development.
Note: Spring MVC, Spring Boot, and web features will be covered in the Microservices module
Dependency Injection makes testing significantly easier:
Key Insight: If your code is hard to test, it's often because of tight coupling!
// Fake implementation for testing
class FakeUserRepository implements UserRepository {
private Map<Long, User> users = new HashMap<>();
private long nextId = 1L;
@Override
public User save(User user) {
if (user.getId() == null) {
user.setId(nextId++);
}
users.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(users.get(id));
}
}
Benefit: No framework needed - just plain Java!
class UserServiceTest {
@Test
void shouldSaveAndFindUser() {
// Arrange - use fake implementation
UserRepository fakeRepo = new FakeUserRepository();
UserService service = new UserService(fakeRepo);
User user = new User("john@example.com");
// Act
User saved = service.createUser(user);
User found = service.findUser(saved.getId());
// Assert
assertNotNull(found);
assertEquals("john@example.com", found.getEmail());
assertEquals(saved.getId(), found.getId());
}
}
Advantage: Real behavior, easy to debug, no magic
// Stub - returns predefined values
class StubEmailService implements EmailService {
private boolean emailSent = false;
private String lastEmail;
@Override
public void sendWelcomeEmail(String email) {
this.emailSent = true;
this.lastEmail = email;
}
public boolean wasEmailSent() {
return emailSent;
}
public String getLastEmail() {
return lastEmail;
}
}
class UserServiceTest {
@Test
void shouldSendWelcomeEmail() {
// Arrange
UserRepository fakeRepo = new FakeUserRepository();
StubEmailService emailStub = new StubEmailService();
UserService service = new UserService(fakeRepo, emailStub);
User user = new User("test@example.com");
// Act
service.createUser(user);
// Assert
assertTrue(emailStub.wasEmailSent());
assertEquals("test@example.com", emailStub.getLastEmail());
}
}
Benefit: Simple verification without mocking framework
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = TestConfig.class)
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void testWithSpringContext() {
User user = new User("john@example.com");
// Use real (test) implementation
User saved = userService.createUser(user);
User found = userService.findUser(saved.getId());
assertNotNull(found);
assertEquals("john@example.com", found.getEmail());
}
}
Spring loads and wires beans automatically - testing real integration!
@Configuration
@ComponentScan(basePackages = "com.example.service")
public class TestConfig {
@Bean
@Primary // Override production bean
public UserRepository testUserRepository() {
return new InMemoryUserRepository(); // Use in-memory fake
}
@Bean
public EmailService testEmailService() {
return new StubEmailService(); // Use stub implementation
}
}
// Test configuration file
// src/test/resources/application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop
Load only specific parts of Spring context for faster tests:
// Test only the service layer
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {UserService.class, TestConfig.class})
class UserServiceSliceTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void testServiceLayer() {
// Only UserService and its dependencies are loaded
User user = new User("test@example.com");
User saved = userService.createUser(user);
assertNotNull(saved.getId());
}
}
Benefit: Faster than loading entire application context
A bean is an object managed by the Spring IoC container.
@Component // Generic Spring-managed component
public class MyComponent { }
@Service // Business logic layer
public class UserService { }
@Repository // Data access layer
public class UserRepository { }
Note: @Controller and @RestController are for web layer (covered in Microservices)
@Service
public class UserService {
private final UserRepository userRepository; // (#1:Final fields ensure immutability)
private final EmailService emailService;
// (#2:Spring automatically injects dependencies - no @Autowired needed!)
public UserService(UserRepository userRepository,
EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public void createUser(User user) {
userRepository.save(user); // (#3:Dependencies are guaranteed to be non-null)
emailService.sendWelcomeEmail(user.getEmail());
}
}
Benefits: Immutability, mandatory dependencies, easier testing
Spring provides multiple ways to configure beans:
We'll focus on annotation and Java-based configuration.
Use @Configuration for third-party classes or complex setup:
@Configuration
public class AppConfig {
@Bean
public DataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb");
dataSource.setUsername("user");
dataSource.setPassword("password");
dataSource.setMaximumPoolSize(10);
dataSource.setConnectionTimeout(30000);
return dataSource;
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource dataSource) {
return new JdbcTemplate(dataSource);
}
}
@Configuration
public class ServiceConfig {
@Bean
public UserRepository userRepository(DataSource dataSource) {
return new JdbcUserRepository(dataSource);
}
@Bean
public EmailService emailService() {
return new SmtpEmailService();
}
@Bean
public UserService userService(UserRepository repository,
EmailService emailService) {
return new UserService(repository, emailService);
}
}
Note: Spring resolves dependencies automatically based on method parameters
@Configuration
public class DataSourceConfig {
@Bean(initMethod = "initialize", destroyMethod = "close")
public CustomDataSource dataSource() {
CustomDataSource ds = new CustomDataSource();
ds.setUrl("jdbc:postgresql://localhost:5432/mydb");
return ds;
}
}
// In the bean class
public class CustomDataSource {
public void initialize() {
// Called after properties are set
System.out.println("Initializing connection pool");
}
public void close() {
// Called before bean is destroyed
System.out.println("Closing connection pool");
}
}
| Scope | Description |
|---|---|
| singleton | One instance per Spring container (default) |
| prototype | New instance each time requested |
| request | One instance per HTTP request (web apps) |
| session | One instance per HTTP session (web apps) |
@Scope("prototype")
@Component
public class PrototypeBean { }
@Scope(ConfigurableBeanFactory.SCOPE_SINGLETON)
@Component
public class SingletonBean { }
public interface PaymentService {
void processPayment(Payment payment);
}
@Service("creditCardPayment")
public class CreditCardPaymentService implements PaymentService { }
@Service("paypalPayment")
public class PayPalPaymentService implements PaymentService { }
// Inject specific implementation
@Service
public class OrderService {
public OrderService(@Qualifier("creditCardPayment")
PaymentService paymentService) {
this.paymentService = paymentService;
}
}
public interface NotificationService {
void notify(String message);
}
@Service
@Primary // This will be injected by default
public class EmailNotificationService implements NotificationService {
public void notify(String message) {
// Send email
}
}
@Service
public class SmsNotificationService implements NotificationService {
public void notify(String message) {
// Send SMS
}
}
@Service
public class AlertService {
// EmailNotificationService injected automatically
public AlertService(NotificationService notificationService) {
this.notificationService = notificationService;
}
}
src/main/resources/application.properties:
# Database configuration
db.url=jdbc:postgresql://localhost:5432/mydb
db.username=admin
db.password=secret
db.pool.size=20
# Application configuration
app.name=My Application
app.version=1.0.0
app.email.from=noreply@example.com
app.email.smtp.host=smtp.gmail.com
app.email.smtp.port=587
# Feature flags
feature.notifications.enabled=true
feature.analytics.enabled=false
@Component
public class EmailService {
@Value("${app.email.from}")
private String fromEmail;
@Value("${app.email.smtp.host}")
private String smtpHost;
@Value("${app.email.smtp.port}")
private int smtpPort;
// Default value if property not found
@Value("${feature.notifications.enabled:true}")
private boolean notificationsEnabled;
// SpEL expressions
@Value("#{systemProperties['user.home']}")
private String userHome;
public void sendEmail(String to, String subject, String body) {
if (!notificationsEnabled) {
return;
}
// Send email using configured properties
}
}
@Configuration
@ConfigurationProperties(prefix = "app.email") // (#1:Binds all app.email.* properties)
public class EmailProperties {
private String from; // (#2:Maps to app.email.from)
private Smtp smtp = new Smtp(); // (#3:Nested configuration object)
private boolean enabled = true;
public static class Smtp {
private String host; // (#4:Maps to app.email.smtp.host)
private int port;
private String username;
private String password;
private boolean auth = true;
private boolean starttls = true;
// Getters and setters
}
// Getters and setters
}
app.email.from=noreply@example.com
app.email.smtp.host=smtp.gmail.com
app.email.smtp.port=587
app.email.smtp.auth=true
@Service
public class EmailService {
private final EmailProperties emailProperties;
public EmailService(EmailProperties emailProperties) {
this.emailProperties = emailProperties;
}
public void sendEmail(String to, String subject, String body) {
if (!emailProperties.isEnabled()) {
log.info("Email service is disabled");
return;
}
Properties props = new Properties();
props.put("mail.smtp.host", emailProperties.getSmtp().getHost());
props.put("mail.smtp.port", emailProperties.getSmtp().getPort());
props.put("mail.smtp.auth", emailProperties.getSmtp().isAuth());
// Create and send email
}
}
@Configuration
@ConfigurationProperties(prefix = "app.email")
@Validated // Enable validation
public class EmailProperties {
@NotBlank(message = "Email 'from' address is required")
@Email
private String from;
@Valid
private Smtp smtp = new Smtp();
public static class Smtp {
@NotBlank
private String host;
@Min(1)
@Max(65535)
private int port;
@NotBlank
private String username;
@NotBlank
private String password;
// Getters and setters
}
}
Activate different configurations for dev, test, prod:
# application.properties (base configuration)
app.name=My Application
# application-dev.properties
db.url=jdbc:h2:mem:testdb
logging.level.root=DEBUG
feature.analytics.enabled=false
# application-test.properties
db.url=jdbc:h2:mem:testdb
logging.level.root=INFO
# application-prod.properties
db.url=jdbc:postgresql://prod-db:5432/mydb
logging.level.root=WARN
feature.analytics.enabled=true
Activate via:
java -jar myapp.jar --spring.profiles.active=prod
@Configuration
public class DataSourceConfig {
@Bean
@Profile("dev") // (#1:Only active when 'dev' profile is enabled)
public DataSource devDataSource() {
// (#2:H2 in-memory database for development - fast and disposable)
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("schema.sql")
.addScript("test-data.sql")
.build();
}
@Bean
@Profile("prod") // (#3:Only active when 'prod' profile is enabled)
public DataSource prodDataSource() {
// (#4:Production PostgreSQL with connection pooling)
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:postgresql://prod-db:5432/mydb");
ds.setUsername("prod_user");
ds.setPassword("prod_password");
ds.setMaximumPoolSize(50);
return ds;
}
}
@Configuration
public class ServiceConfig {
@Bean
@Profile("cache")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("users", "products");
}
@Bean
@Profile("mock-email")
public EmailService mockEmailService() {
return new MockEmailService();
}
@Bean
@Profile("!mock-email") // Active when mock-email NOT active
public EmailService realEmailService() {
return new SmtpEmailService();
}
}
# Activate multiple profiles
java -jar myapp.jar --spring.profiles.active=dev,cache,mock-email
@Configuration
public class ConditionalConfig {
@Bean
@ConditionalOnProperty(
name = "cache.enabled",
havingValue = "true",
matchIfMissing = false
)
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("users");
}
@Bean
@ConditionalOnMissingBean // Only if no other CacheManager exists
public CacheManager defaultCacheManager() {
return new NoOpCacheManager();
}
@Bean
@ConditionalOnClass(name = "com.amazonaws.services.s3.AmazonS3")
public StorageService s3StorageService() {
return new S3StorageService();
}
}
@Configuration
@PropertySource("classpath:custom.properties")
@PropertySource("file:/etc/myapp/config.properties")
public class ExternalConfig {
@Value("${custom.setting}")
private String customSetting;
}
// Multiple property sources
@Configuration
@PropertySources({
@PropertySource("classpath:app.properties"),
@PropertySource("classpath:database.properties")
})
public class MultiConfig { }
Property precedence (highest to lowest):
@Configuration
@ComponentScan(
basePackages = {"com.example.service", "com.example.repository"},
includeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = Service.class
),
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com.example.legacy.*"
)
)
public class AppConfig { }
// Or use type-safe base package classes
@ComponentScan(basePackageClasses = {
UserService.class,
ProductRepository.class
})
public class TypeSafeConfig { }
@Component
public class DatabaseConnectionPool {
@PostConstruct
public void init() {
// Called after dependency injection
// Initialize connection pool
log.info("Initializing connection pool");
}
@PreDestroy
public void cleanup() {
// Called before bean is destroyed
// Close all connections
log.info("Closing connection pool");
}
}
// Alternative: implement interfaces
@Component
public class MyBean implements InitializingBean, DisposableBean {
@Override
public void afterPropertiesSet() {
// Same as @PostConstruct
}
@Override
public void destroy() {
// Same as @PreDestroy
}
}
While manual test doubles work well, Mockito provides powerful features for complex scenarios:
Add Mockito dependency to use these features
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@ExtendWith(MockitoExtension.class) // (#1:JUnit 5 extension for Mockito support)
class UserServiceTest {
@Mock // (#2:Create mock instances)
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks // (#3:Automatically injects @Mock dependencies into UserService)
private UserService userService;
@Test
void shouldCreateUserAndSendEmail() {
User user = new User("test@example.com");
when(userRepository.save(any(User.class))) // (#4:Stub the mock behavior)
.thenReturn(user);
userService.createUser(user);
verify(userRepository).save(user); // (#5:Verify method was called)
verify(emailService).sendWelcomeEmail("test@example.com");
}
}
@Test
void shouldVerifyInteractions() {
UserRepository mockRepo = mock(UserRepository.class);
UserService service = new UserService(mockRepo);
User user = new User("test@example.com");
service.createUser(user);
// Verify exact call
verify(mockRepo).save(user);
// Verify number of calls
verify(mockRepo, times(1)).save(any());
// Verify no other interactions
verifyNoMoreInteractions(mockRepo);
// Verify never called
verify(mockRepo, never()).delete(any());
// Verify call order
InOrder inOrder = inOrder(mockRepo);
inOrder.verify(mockRepo).save(user);
}
@ExtendWith(MockitoExtension.class)
class EmailServiceTest {
@Mock
private EmailSender emailSender;
@InjectMocks
private NotificationService notificationService;
@Captor
private ArgumentCaptor<Email> emailCaptor;
@Test
void shouldSendCorrectEmail() {
notificationService.notifyUser("user@example.com", "Welcome");
// Capture the argument passed to emailSender
verify(emailSender).send(emailCaptor.capture());
Email sentEmail = emailCaptor.getValue();
assertEquals("user@example.com", sentEmail.getTo());
assertEquals("Welcome", sentEmail.getSubject());
assertTrue(sentEmail.getBody().contains("Welcome"));
}
}
class SpyVsMockTest {
@Mock
private List<String> mockList; // Completely fake
@Spy
private List<String> spyList = new ArrayList<>(); // Real object
@Test
void demonstrateDifference() {
// Mock - all methods stubbed
mockList.add("one");
assertEquals(0, mockList.size()); // Still 0!
// Spy - real methods called unless stubbed
spyList.add("one");
assertEquals(1, spyList.size()); // Actually adds
// Can stub specific methods on spy
when(spyList.size()).thenReturn(100);
assertEquals(100, spyList.size());
}
}
Best practice: Start with manual doubles, add Mockito when needed
Task: Create a user management service with dependency injection
User model with id, name, emailUserRepository interface with CRUD methodsInMemoryUserRepository implementationUserService with constructor injectionUserServiceTask: Write comprehensive tests for a notification system
NotificationService that depends on EmailSenderArgumentCaptor to verify email contentverify() to check method invocations@Test(expected = ...)Task: Implement comprehensive configuration
application.properties with database and email settingsDatabaseProperties class with @ConfigurationPropertiesTask: Handle multiple payment methods
PaymentProcessor interfaceCreditCardProcessor and PayPalProcessorPaymentService that can switch processorsTask: Understand different bean scopes and lifecycle hooks
CacheService with @PostConstruct and @PreDestroyRequestContext bean@Lookup or ObjectFactory to fix prototype injectionTask: Configure a multi-module project with profiles
com.example.service, com.example.repository, com.example.configTask: Build a complete order processing system
Order, Product, Customer modelsOrderService with DI for repository, validator, notification// Avoid this - hard to test, hides dependencies
@Autowired
private UserRepository repository;
// A depends on B, B depends on A - BAD!
@Service
public class ServiceA {
@Autowired private ServiceB serviceB;
}
@Service
public class ServiceB {
@Autowired private ServiceA serviceA;
}
Constructor injection doesn't need @Autowired in modern Spring
shouldReturnUserWhenValidIdProvided()In this module, you learned:
Next Module: Data Persistence with JPA & Hibernate
Spring Boot, web features, and microservices patterns in Module 5