Spring Test

Why Spring Testing Matters

Production Spring applications require comprehensive integration testing to validate framework behavior—verifying dependency injection wiring, database transactions, REST endpoints, security configurations, and message listeners. Manual test setup with new instances requires creating entire application contexts, manually wiring dependencies, and duplicating production configuration—verbose, error-prone, and slow. In production systems processing zakat calculations with Spring Data repositories, transactional services, and REST controllers, Spring Test’s @ContextConfiguration with test slices (@WebMvcTest, @DataJpaTest) provides declarative test context loading, automatic dependency injection, and focused testing—eliminating manual context creation that causes configuration drift, slow test suites, and missed integration bugs.

Manual Test Setup Baseline

Manual Spring testing requires creating and wiring components explicitly:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// => Domain model: zakat calculation
public class ZakatCalculation {
    private String accountId;
    private double wealth;
    private double nisab;
    private double zakatAmount;

    public ZakatCalculation(String accountId, double wealth, double nisab) {
        this.accountId = accountId;
        this.wealth = wealth;
        this.nisab = nisab;
        this.zakatAmount = calculateZakat(wealth, nisab);
    }

    private double calculateZakat(double wealth, double nisab) {
        return wealth >= nisab ? wealth * 0.025 : 0.0;
    }

    // => Getters
    public String getAccountId() { return accountId; }
    public double getWealth() { return wealth; }
    public double getNisab() { return nisab; }
    public double getZakatAmount() { return zakatAmount; }
}

// => Repository interface: data access
public interface ZakatCalculationRepository {
    ZakatCalculation save(ZakatCalculation calculation);
    ZakatCalculation findByAccountId(String accountId);
}

// => Manual repository implementation for testing
// => PROBLEM: Must create test implementation, duplicates production code
public class InMemoryZakatCalculationRepository implements ZakatCalculationRepository {
    // => In-memory storage: simple map
    private final Map<String, ZakatCalculation> storage = new HashMap<>();

    @Override
    public ZakatCalculation save(ZakatCalculation calculation) {
        // => Store calculation by account ID
        storage.put(calculation.getAccountId(), calculation);
        return calculation;
    }

    @Override
    public ZakatCalculation findByAccountId(String accountId) {
        // => Retrieve calculation
        return storage.get(accountId);
    }
}

// => Service class: business logic
public class ZakatCalculationService {
    // => Dependency: repository
    // => PROBLEM: Manual dependency management
    private final ZakatCalculationRepository repository;

    public ZakatCalculationService(ZakatCalculationRepository repository) {
        this.repository = repository;
    }

    // => Business method: calculate and save
    public ZakatCalculation calculateAndSave(String accountId, double wealth, double nisab) {
        // => Create calculation
        ZakatCalculation calculation = new ZakatCalculation(accountId, wealth, nisab);

        // => Save to repository
        repository.save(calculation);

        return calculation;
    }

    // => Retrieve calculation
    public ZakatCalculation getCalculation(String accountId) {
        return repository.findByAccountId(accountId);
    }
}

// => Manual test: explicit wiring
public class ManualZakatCalculationServiceTest {

    // => Test fields: manually created dependencies
    // => PROBLEM: Must create all dependencies manually
    private ZakatCalculationRepository repository;
    private ZakatCalculationService service;

    // => Setup method: runs before each test
    // => PROBLEM: Duplicates production wiring logic
    @org.junit.jupiter.api.BeforeEach
    public void setUp() {
        // => Create repository instance
        // => PROBLEM: Test implementation differs from production (JPA)
        repository = new InMemoryZakatCalculationRepository();

        // => Create service with repository
        // => PROBLEM: Must manually wire dependencies
        service = new ZakatCalculationService(repository);
    }

    @Test
    public void calculateAndSave_shouldStoreCalculation() {
        // => Arrange: test data
        String accountId = "ACC001";
        double wealth = 100000.0;
        double nisab = 85.0;

        // => Act: call service method
        ZakatCalculation result = service.calculateAndSave(accountId, wealth, nisab);

        // => Assert: verify calculation stored
        assertNotNull(result);
        // => Verify account ID
        assertEquals(accountId, result.getAccountId());
        // => Verify zakat amount: 100000 * 0.025 = 2500
        assertEquals(2500.0, result.getZakatAmount(), 0.01);

        // => Verify retrieval
        ZakatCalculation retrieved = service.getCalculation(accountId);
        // => Retrieved calculation should match saved
        assertNotNull(retrieved);
        assertEquals(accountId, retrieved.getAccountId());
    }

    @Test
    public void calculateAndSave_belowNisab_shouldCalculateZero() {
        // => Arrange: wealth below nisab threshold
        String accountId = "ACC002";
        double wealth = 50.0;
        double nisab = 85.0;

        // => Act
        ZakatCalculation result = service.calculateAndSave(accountId, wealth, nisab);

        // => Assert: zakat should be 0
        assertEquals(0.0, result.getZakatAmount(), 0.01);
    }
}

Limitations:

  • Manual dependency creation: Must instantiate all dependencies explicitly
  • Test implementation divergence: Test repository differs from production (JPA)
  • Configuration duplication: Production Spring config not used in tests
  • No Spring features: Cannot test transactions, caching, security
  • Slow test development: Boilerplate wiring for every test class
  • Integration gaps: Tests miss Spring-specific behavior (proxies, AOP)
  • No mock injection: Must manually create and wire mocks

Spring Test Solution

Spring Test provides declarative test context management with dependency injection:

Configuration and @ContextConfiguration

import org.springframework.context.annotation.*;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import javax.persistence.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

// => Spring configuration for testing
@Configuration
// => @ComponentScan: auto-discovers components
@ComponentScan(basePackages = "com.example.zakat")
public class TestConfig {
    // => Configuration beans can be defined here
    // => BENEFIT: Centralized test configuration
}

// => Spring Data repository: production implementation
// => @Repository: Spring component for data access
@Repository
public class JpaZakatCalculationRepository implements ZakatCalculationRepository {
    // => EntityManager: JPA database access
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public ZakatCalculation save(ZakatCalculation calculation) {
        // => JPA persist: saves to database
        entityManager.persist(calculation);
        return calculation;
    }

    @Override
    public ZakatCalculation findByAccountId(String accountId) {
        // => JPA query: retrieves from database
        TypedQuery<ZakatCalculation> query = entityManager.createQuery(
            "SELECT z FROM ZakatCalculation z WHERE z.accountId = :accountId",
            ZakatCalculation.class
        );
        query.setParameter("accountId", accountId);
        return query.getSingleResult();
    }
}

// => Service with Spring annotations
// => @Service: Spring component for business logic
@Service
public class SpringZakatCalculationService {
    // => Dependency injection: Spring autowires repository
    // => BENEFIT: No manual wiring
    private final ZakatCalculationRepository repository;

    // => Constructor injection: recommended pattern
    public SpringZakatCalculationService(ZakatCalculationRepository repository) {
        this.repository = repository;
    }

    public ZakatCalculation calculateAndSave(String accountId, double wealth, double nisab) {
        ZakatCalculation calculation = new ZakatCalculation(accountId, wealth, nisab);
        return repository.save(calculation);
    }

    public ZakatCalculation getCalculation(String accountId) {
        return repository.findByAccountId(accountId);
    }
}

// => Spring Test: declarative context loading
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// => @SpringJUnitConfig: loads Spring context for tests
// => Combines @ExtendWith(SpringExtension.class) + @ContextConfiguration
// => BENEFIT: One annotation for Spring test setup
@SpringJUnitConfig(classes = TestConfig.class)
public class SpringZakatCalculationServiceTest {

    // => @Autowired: Spring injects dependency
    // => BENEFIT: No manual service creation
    @Autowired
    private SpringZakatCalculationService service;

    // => @Autowired: Spring injects repository
    // => Useful for verifying repository state
    @Autowired
    private ZakatCalculationRepository repository;

    @Test
    public void calculateAndSave_shouldStoreCalculation() {
        // => Arrange
        String accountId = "ACC001";
        double wealth = 100000.0;
        double nisab = 85.0;

        // => Act: service already wired by Spring
        // => BENEFIT: No manual dependency creation
        ZakatCalculation result = service.calculateAndSave(accountId, wealth, nisab);

        // => Assert
        assertNotNull(result);
        assertEquals(accountId, result.getAccountId());
        assertEquals(2500.0, result.getZakatAmount(), 0.01);

        // => Verify via repository: tests full integration
        ZakatCalculation retrieved = repository.findByAccountId(accountId);
        assertNotNull(retrieved);
        assertEquals(accountId, retrieved.getAccountId());
    }

    @Test
    public void calculateAndSave_belowNisab_shouldCalculateZero() {
        // => Arrange
        String accountId = "ACC002";
        double wealth = 50.0;
        double nisab = 85.0;

        // => Act
        ZakatCalculation result = service.calculateAndSave(accountId, wealth, nisab);

        // => Assert
        assertEquals(0.0, result.getZakatAmount(), 0.01);
    }
}

Mock Beans with @MockBean

import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;

// => Spring Test with mocks
@SpringJUnitConfig(classes = TestConfig.class)
public class SpringZakatCalculationServiceMockTest {

    // => @Autowired: real service instance
    @Autowired
    private SpringZakatCalculationService service;

    // => @MockBean: Spring creates and injects mock
    // => Replaces real repository with mock in context
    // => BENEFIT: No manual mock creation or wiring
    @MockBean
    private ZakatCalculationRepository repository;

    @Test
    public void calculateAndSave_shouldCallRepository() {
        // => Arrange: mock behavior
        String accountId = "ACC001";
        double wealth = 100000.0;
        double nisab = 85.0;

        ZakatCalculation expected = new ZakatCalculation(accountId, wealth, nisab);

        // => Mockito: stub repository.save()
        // => When save() called, return expected calculation
        Mockito.when(repository.save(any(ZakatCalculation.class)))
            .thenReturn(expected);

        // => Act: call service
        // => Service uses mocked repository
        ZakatCalculation result = service.calculateAndSave(accountId, wealth, nisab);

        // => Assert: verify result
        assertNotNull(result);
        assertEquals(accountId, result.getAccountId());

        // => Mockito verify: ensure save() called once
        // => BENEFIT: Verifies service-repository interaction
        Mockito.verify(repository, Mockito.times(1)).save(any(ZakatCalculation.class));
    }

    @Test
    public void getCalculation_shouldCallRepository() {
        // => Arrange: mock repository response
        String accountId = "ACC001";
        ZakatCalculation expected = new ZakatCalculation(accountId, 100000.0, 85.0);

        Mockito.when(repository.findByAccountId(accountId))
            .thenReturn(expected);

        // => Act
        ZakatCalculation result = service.getCalculation(accountId);

        // => Assert
        assertNotNull(result);
        assertEquals(accountId, result.getAccountId());

        // => Verify repository interaction
        Mockito.verify(repository, Mockito.times(1)).findByAccountId(accountId);
    }
}

Test Slices for Focused Testing

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

// => REST Controller: web layer
@org.springframework.web.bind.annotation.RestController
@org.springframework.web.bind.annotation.RequestMapping("/api/zakat")
public class ZakatController {
    private final SpringZakatCalculationService service;

    public ZakatController(SpringZakatCalculationService service) {
        this.service = service;
    }

    // => POST endpoint: calculate zakat
    @org.springframework.web.bind.annotation.PostMapping("/calculate")
    public ZakatCalculation calculate(
            @org.springframework.web.bind.annotation.RequestParam String accountId,
            @org.springframework.web.bind.annotation.RequestParam double wealth,
            @org.springframework.web.bind.annotation.RequestParam double nisab) {
        return service.calculateAndSave(accountId, wealth, nisab);
    }
}

// => @WebMvcTest: loads only web layer components
// => Spring: creates MockMvc, loads controllers, excludes services/repositories
// => BENEFIT: Fast tests, focused on web layer
@WebMvcTest(ZakatController.class)
public class ZakatControllerTest {

    // => @Autowired MockMvc: simulates HTTP requests
    // => Spring Test: provides MockMvc for controller testing
    @Autowired
    private MockMvc mockMvc;

    // => @MockBean: mocks service layer
    // => Controller depends on service, but we test controller in isolation
    @MockBean
    private SpringZakatCalculationService service;

    @Test
    public void calculate_shouldReturnZakatCalculation() throws Exception {
        // => Arrange: mock service behavior
        String accountId = "ACC001";
        double wealth = 100000.0;
        double nisab = 85.0;
        ZakatCalculation expected = new ZakatCalculation(accountId, wealth, nisab);

        Mockito.when(service.calculateAndSave(accountId, wealth, nisab))
            .thenReturn(expected);

        // => Act & Assert: HTTP POST request
        // => MockMvc: performs request, verifies response
        mockMvc.perform(
                MockMvcRequestBuilders.post("/api/zakat/calculate")
                    .param("accountId", accountId)
                    .param("wealth", String.valueOf(wealth))
                    .param("nisab", String.valueOf(nisab))
            )
            // => Expect HTTP 200 OK
            .andExpect(MockMvcResultMatchers.status().isOk())
            // => Expect JSON field: accountId
            .andExpect(MockMvcResultMatchers.jsonPath("$.accountId").value(accountId))
            // => Expect JSON field: zakatAmount
            .andExpect(MockMvcResultMatchers.jsonPath("$.zakatAmount").value(2500.0));

        // => Verify service interaction
        Mockito.verify(service, Mockito.times(1)).calculateAndSave(accountId, wealth, nisab);
    }
}

// => @DataJpaTest: loads only JPA components
// => Spring: creates in-memory database, loads repositories, excludes controllers/services
// => BENEFIT: Fast tests, focused on data layer
@DataJpaTest
public class JpaZakatCalculationRepositoryTest {

    // => @Autowired: Spring injects repository
    // => Uses in-memory H2 database for testing
    @Autowired
    private ZakatCalculationRepository repository;

    @Test
    public void save_shouldPersistCalculation() {
        // => Arrange
        ZakatCalculation calculation = new ZakatCalculation("ACC001", 100000.0, 85.0);

        // => Act: save to database
        // => Spring Data JPA: auto-commits transaction
        ZakatCalculation saved = repository.save(calculation);

        // => Assert: verify saved
        assertNotNull(saved);
        assertEquals("ACC001", saved.getAccountId());

        // => Verify retrieval
        ZakatCalculation retrieved = repository.findByAccountId("ACC001");
        assertNotNull(retrieved);
        assertEquals("ACC001", retrieved.getAccountId());
        assertEquals(2500.0, retrieved.getZakatAmount(), 0.01);
    }
}

Benefits:

  • Declarative context loading: @SpringJUnitConfig loads Spring context automatically
  • Automatic dependency injection: @Autowired injects dependencies
  • Mock integration: @MockBean creates and injects mocks
  • Test slices: @WebMvcTest, @DataJpaTest load only necessary components
  • Production configuration: Tests use actual Spring configuration
  • Integration testing: Verifies Spring-specific behavior (DI, transactions)
  • Fast test development: Minimal boilerplate, no manual wiring

Spring Test Context Lifecycle Diagram

  sequenceDiagram
    participant JUnit as JUnit Test Runner
    participant Spring as Spring TestContext
    participant Context as ApplicationContext
    participant Service as ZakatService
    participant Repository as Repository (Mock)

    JUnit->>Spring: @SpringJUnitConfig detected
    Spring->>Context: Load ApplicationContext (TestConfig.class)
    Context->>Context: Scan components (@Service, @Repository)
    Context->>Repository: Create @MockBean instance
    Context->>Service: Create service, inject mocked repository
    Spring-->>JUnit: Context ready

    JUnit->>JUnit: @Test method execution
    JUnit->>Service: @Autowired injection (via Spring)
    Service->>Repository: service.calculateAndSave()
    Repository-->>Service: Mocked response
    Service-->>JUnit: Result

    JUnit->>JUnit: Assertions
    JUnit->>Repository: Mockito.verify() interactions

    Note over Spring,Context: Context cached between tests
    Note over Repository: @MockBean replaces real repository
    Note over Service: Real service with mocked dependencies

    style Spring fill:#0173B2,stroke:#333,stroke-width:2px,color:#fff
    style Context fill:#029E73,stroke:#333,stroke-width:2px,color:#fff
    style Service fill:#DE8F05,stroke:#333,stroke-width:2px,color:#fff
    style Repository fill:#CC78BC,stroke:#333,stroke-width:2px,color:#fff

Production Patterns

Test Property Sources

import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.beans.factory.annotation.Value;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// => @TestPropertySource: overrides application properties
// => Spring: loads test-specific configuration
@SpringJUnitConfig(classes = TestConfig.class)
@TestPropertySource(properties = {
    "zakat.nisab=85.0",
    "zakat.rate=0.025",
    "spring.datasource.url=jdbc:h2:mem:testdb"
})
public class ZakatServiceWithPropertiesTest {

    // => @Value: injects property values
    // => Spring: resolves from @TestPropertySource
    @Value("${zakat.nisab}")
    private double nisab;

    @Value("${zakat.rate}")
    private double rate;

    @Test
    public void properties_shouldBeLoaded() {
        // => Verify test properties loaded
        assertEquals(85.0, nisab, 0.01);
        assertEquals(0.025, rate, 0.0001);
    }
}

Transactional Tests with Rollback

import org.springframework.transaction.annotation.Transactional;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// => @Transactional: wraps each test in transaction
// => Spring: rolls back transaction after test (no database pollution)
@SpringJUnitConfig(classes = TestConfig.class)
@Transactional
public class TransactionalZakatServiceTest {

    @Autowired
    private SpringZakatCalculationService service;

    @Autowired
    private ZakatCalculationRepository repository;

    @Test
    public void calculateAndSave_shouldRollbackAfterTest() {
        // => Arrange
        String accountId = "ACC001";

        // => Act: save calculation
        service.calculateAndSave(accountId, 100000.0, 85.0);

        // => Assert: calculation exists in transaction
        ZakatCalculation retrieved = repository.findByAccountId(accountId);
        assertNotNull(retrieved);

        // => BENEFIT: Transaction rolls back after test
        // => Next test sees clean database (no ACC001)
    }

    @Test
    public void secondTest_shouldNotSeeFirstTestData() {
        // => Previous test data rolled back
        // => BENEFIT: Test isolation, no cleanup needed
        ZakatCalculation retrieved = repository.findByAccountId("ACC001");
        assertNull(retrieved);  // ACC001 not found (rolled back)
    }
}

Integration Testing with @SpringBootTest

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

// => @SpringBootTest: loads full application context
// => Spring Boot: starts embedded server, loads all components
// => BENEFIT: Full integration testing (all layers)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ZakatIntegrationTest {

    // => @Autowired TestRestTemplate: HTTP client for testing
    // => Spring Boot: provides TestRestTemplate for REST testing
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    public void calculateEndpoint_shouldReturnZakatCalculation() {
        // => Arrange: request parameters
        String url = "/api/zakat/calculate?accountId=ACC001&wealth=100000&nisab=85";

        // => Act: HTTP POST request
        // => TestRestTemplate: real HTTP call to embedded server
        ResponseEntity<ZakatCalculation> response = restTemplate.postForEntity(
            url,
            null,
            ZakatCalculation.class
        );

        // => Assert: verify HTTP response
        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertNotNull(response.getBody());
        assertEquals("ACC001", response.getBody().getAccountId());
        assertEquals(2500.0, response.getBody().getZakatAmount(), 0.01);
    }
}

Trade-offs and When to Use

ApproachSetup ComplexitySpring FeaturesTest SpeedProduction AccuracyIsolation
Manual Test SetupHighNoneFastLowHigh
@SpringJUnitConfigLowFullMediumHighMedium
@WebMvcTestLowWeb LayerFastHigh (web)High
@DataJpaTestLowData LayerFastHigh (data)High
@SpringBootTest (full)LowFullSlowVery HighLow
@SpringBootTest (webEnv)LowFull + ServerSlowestProduction-likeNone

When to Use Manual Test Setup:

  • Unit testing POJOs (no Spring dependencies)
  • Testing utility classes
  • Learning testing fundamentals

When to Use @SpringJUnitConfig:

  • Testing service layer with real dependencies
  • Verifying Spring DI wiring
  • Integration testing multiple components

When to Use @WebMvcTest:

  • Testing REST controllers in isolation
  • Verifying HTTP request handling
  • Fast web layer tests with mocked services

When to Use @DataJpaTest:

  • Testing JPA repositories in isolation
  • Verifying database queries and mappings
  • Fast data layer tests with in-memory database

When to Use @SpringBootTest:

  • Full integration testing (all layers)
  • End-to-end testing with embedded server
  • Testing component interactions (controller → service → repository)

Best Practices

1. Use Test Slices for Focused Testing

// ✅ Fast web layer test
@WebMvcTest(ZakatController.class)
public class ZakatControllerTest { }

// ✅ Fast data layer test
@DataJpaTest
public class ZakatRepositoryTest { }

// ❌ Slow full context (use only when needed)
@SpringBootTest
public class FullContextTest { }

2. Mock External Dependencies

@SpringJUnitConfig(classes = TestConfig.class)
public class ZakatServiceTest {
    @Autowired
    private ZakatService service;

    // ✅ Mock external repository
    @MockBean
    private ZakatRepository repository;
}

3. Use @Transactional for Database Tests

// ✅ Automatic rollback (no cleanup needed)
@SpringJUnitConfig
@Transactional
public class ZakatServiceTest { }

4. Use @TestPropertySource for Test Configuration

@SpringJUnitConfig
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:h2:mem:testdb",
    "zakat.nisab=85.0"
})
public class ZakatServiceTest { }

5. Verify Mock Interactions

@Test
public void test_shouldCallRepository() {
    service.calculateAndSave("ACC001", 100000.0, 85.0);

    // ✅ Verify repository called
    Mockito.verify(repository, Mockito.times(1))
        .save(any(ZakatCalculation.class));
}

See Also

Last updated