Back to Testing: Unit, Integration, Testcontainers
Q1Question 1 of 1

What are the Spring testing annotations?

Key Concepts
  • Unit tests use Mockito (@Mock, @InjectMocks) with no Spring context and run in milliseconds
  • @WebMvcTest loads only the controller layer with MockMvc and mocked services for testing HTTP behavior
  • @DataJpaTest loads only the JPA layer with an embedded or Testcontainers database for testing queries
  • @SpringBootTest loads the full application context for integration tests and is reserved for multi-layer testing
  • @Mock creates a full stub returning defaults; @Spy wraps a real object and calls real methods unless overridden
  • ArgumentCaptor captures arguments passed to mocks so you can assert on the exact values that were saved
  • Testcontainers spins up real Docker containers (PostgreSQL, Redis, Kafka) for tests, catching dialect-specific bugs

Definition

Unit tests use Mockito with no Spring context for millisecond-fast testing, @WebMvcTest loads only the controller layer, @DataJpaTest loads only the JPA layer, and @SpringBootTest loads everything for full integration tests.
  • Unit test – tests one class in isolation with Mockito mocks replacing all dependencies, no Spring context, runs in milliseconds.
  • @WebMvcTest – loads only the web layer (controller, MockMvc, Jackson, validators, @ControllerAdvice) with all services mocked via @MockBean.
  • @DataJpaTest – loads only the JPA layer (entities, repositories, EntityManager) with a test database, rolling back after each test.
  • @SpringBootTest – loads the full application context for integration tests that exercise multiple layers together.
  • Testcontainers – a library that spins up real Docker containers (PostgreSQL, Redis, Kafka) for tests and tears them down afterward.
  • @Mock vs @Spy – @Mock returns defaults for all methods; @Spy wraps a real object and calls real methods unless explicitly overridden.
  • ArgumentCaptor – captures the arguments passed to a mock's method so you can assert on the exact values.

Testing a National Bank of India branch at different levels. A unit test tests the teller's math with fake money and a fake vault (mocks). A @WebMvcTest tests the reception desk with a fake back office. A @DataJpaTest tests the vault with a real mini-vault (Testcontainers with PostgreSQL). A @SpringBootTest tests the entire branch with everything real, from the front door to the vault.

Understanding the Concepts

What is the testing pyramid?

A healthy test suite follows the testing pyramid: many fast unit tests at the bottom, some slice tests in the middle, fewer integration tests, and very few end-to-end tests at the top. Each level trades speed for confidence.

| Level | Annotation | Speed | What it Tests | Spring Context |
|---|---|---|---|---|
| Unit | None (Mockito) | Milliseconds | One class in isolation | No |
| Web slice | @WebMvcTest | Hundreds of ms | Controller + JSON + validation | Partial |
| JPA slice | @DataJpaTest | Seconds | Repository + queries + constraints | Partial |
| Integration | @SpringBootTest | Seconds to minutes | Full application flow | Full |
| E2E | @SpringBootTest + REST client | Minutes | Entire system + real infra | Full |

TEXT
        /\        E2E            few, slow, brittle
       /  \       Integration    some, medium
      /    \      Slice tests    more (@WebMvcTest, @DataJpaTest)
     /______\     Unit tests     MANY, milliseconds

How do you write unit tests with Mockito?

Unit tests are the fastest tests. No Spring context, no database, no network. You instantiate the class under test and replace its collaborators with mocks. @Mock creates a stub returning defaults. @InjectMocks builds the subject and injects the mocks. when(...).thenReturn(...) programs behavior. verify(...) asserts that an interaction happened.

JAVA
package com.bank.service;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class InterestCalculatorTest {

    @Mock
    private InterestRateRepository rateRepository;

    @InjectMocks
    private InterestCalculator calculator;

    @Test
    void calculatesInterestForSavingsAccount() {
        // Arrange: program the mock
        when(rateRepository.findRate("SAVINGS"))
            .thenReturn(new BigDecimal("0.065"));

        // Act: call the method under test
        BigDecimal interest = calculator.calculate(
            "SAV2001", new BigDecimal("100000"), 12);

        // Assert: verify the result
        assertThat(interest).isEqualByComparingTo("6500.00");
        verify(rateRepository).findRate("SAVINGS");
    }

    @Test
    void throwsWhenAccountNotFound() {
        when(rateRepository.findRate("SAVINGS"))
            .thenThrow(new AccountNotFoundException("SAV9999"));

        assertThatThrownBy(() -> calculator.calculate(
                "SAV9999", new BigDecimal("100000"), 12))
            .isInstanceOf(AccountNotFoundException.class)
            .hasMessageContaining("SAV9999");
    }
}
TEXT
$ mvn test -pl core-service -Dtest=InterestCalculatorTest
Tests run: 2, Failures: 0, Errors: 0, Time: 0.045 sec

What is the difference between @Mock and @Spy?

@Mock creates a full stub. Every method returns a default value (null, 0, false, empty collection) unless you explicitly program behavior with when(...).thenReturn(...). @Spy wraps a real object. It calls the real method implementation unless you override a specific method.

| Aspect | @Mock | @Spy |
|---|---|---|
| Object | Entirely fake | Wraps a real instance |
| Default behavior | Returns defaults (null, 0) | Calls real methods |
| Override | when().thenReturn() | doReturn().when() |
| Use case | Replace a dependency | Partially override a real class |

JAVA
package com.bank.service;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class MockVsSpyTest {

    @Mock
    private List<String> mockList;

    @Spy
    private ArrayList<String> spyList = new ArrayList<>();

    @Test
    void mockReturnsDefaults() {
        // Mock returns 0 for size() without programming
        assertThat(mockList.size()).isEqualTo(0);
        mockList.add("SAV2001");
        // add() was called but mock does not actually store anything
        assertThat(mockList.size()).isEqualTo(0);
    }

    @Test
    void spyCallsRealMethods() {
        // Spy calls the REAL add() method
        spyList.add("SAV2001");
        assertThat(spyList.size()).isEqualTo(1);

        // Override one specific method
        doReturn(100).when(spyList).size();
        assertThat(spyList.size()).isEqualTo(100);
    }
}

How do you use ArgumentCaptor?

ArgumentCaptor captures the arguments passed to a mock's method. This is essential when you need to verify not just that a method was called, but exactly what values were passed to it.

JAVA
package com.bank.service;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class TransferServiceTest {

    @Mock
    private AuditRepository auditRepository;

    @InjectMocks
    private TransferService transferService;

    @Captor
    private ArgumentCaptor<AuditEntry> auditCaptor;

    @Test
    void capturesAuditEntryOnTransfer() {
        transferService.transfer("SAV2001", "SAV2002", new BigDecimal("50000"));

        // Capture what was passed to auditRepository.save()
        verify(auditRepository).save(auditCaptor.capture());

        AuditEntry captured = auditCaptor.getValue();
        assertThat(captured.getFromAccount()).isEqualTo("SAV2001");
        assertThat(captured.getToAccount()).isEqualTo("SAV2002");
        assertThat(captured.getAmount()).isEqualByComparingTo("50000");
        assertThat(captured.getType()).isEqualTo("NEFT_TRANSFER");
    }
}

How does @WebMvcTest work?

@WebMvcTest loads only the web layer: the controller, Spring MVC infrastructure (MockMvc, Jackson, validators, @ControllerAdvice). It does NOT load services, repositories, or the database. You supply services as @MockBean.

Use it to verify JSON serialization and deserialization, request validation, HTTP status codes, and error response format.

JAVA
package com.bank.controller;

import com.bank.service.AccountService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.http.MediaType.APPLICATION_JSON;

@WebMvcTest(AccountController.class)
class AccountControllerTest {

    @Autowired
    private MockMvc mvc;

    @MockBean
    private AccountService accountService;

    @Test
    void returnsBalanceForValidAccount() throws Exception {
        when(accountService.getBalance("SAV2001"))
            .thenReturn(new BigDecimal("150000.00"));

        mvc.perform(get("/api/v1/accounts/SAV2001/balance"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$").value(150000.00));
    }

    @Test
    void returns400ForInvalidTransferAmount() throws Exception {
        String invalidRequest = "{\"fromAccount\":\"SAV2001\"," +
            "\"toAccount\":\"SAV2002\",\"amount\":-500}";

        mvc.perform(post("/api/v1/transfers")
                .contentType(APPLICATION_JSON)
                .content(invalidRequest))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors[0].field").value("amount"));
    }

    @Test
    void returns404ForNonExistentAccount() throws Exception {
        when(accountService.getBalance("SAV9999"))
            .thenThrow(new AccountNotFoundException("SAV9999"));

        mvc.perform(get("/api/v1/accounts/SAV9999/balance"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.message").value("Account SAV9999 not found"));
    }
}

How does @DataJpaTest work with Testcontainers?

@DataJpaTest loads only the JPA layer: entities, repositories, EntityManager, and a transaction that rolls back after each test. By default it uses an embedded H2 database, but H2 lies about PostgreSQL behavior. Testcontainers spins up a real PostgreSQL container in Docker for the test.

JAVA
package com.bank.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class AccountRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private AccountRepository accountRepository;

    @Test
    void findsAccountsByMinimumBalance() {
        accountRepository.save(new Account("SAV2001", "Priya",
            new BigDecimal("150000.00")));
        accountRepository.save(new Account("SAV2002", "Rajesh",
            new BigDecimal("5000.00")));

        List<Account> highBalance = accountRepository
            .findByBalanceGreaterThan(new BigDecimal("10000"));

        assertThat(highBalance).hasSize(1);
        assertThat(highBalance.get(0).getAccountId()).isEqualTo("SAV2001");
    }

    @Test
    void enforceUniqueAccountIdConstraint() {
        accountRepository.save(new Account("SAV2001", "Priya",
            new BigDecimal("150000.00")));

        // Real PostgreSQL enforces the unique constraint
        // H2 might not behave identically
        org.junit.jupiter.api.Assertions.assertThrows(
            org.springframework.dao.DataIntegrityViolationException.class,
            () -> accountRepository.save(new Account("SAV2001", "Duplicate",
                new BigDecimal("1000.00"))));
    }
}
TEXT
$ mvn test -Dtest=AccountRepositoryTest
[INFO] Testcontainers: Starting PostgreSQL container...
[INFO] Testcontainers: PostgreSQL container started in 3.2 seconds
Tests run: 2, Failures: 0, Errors: 0, Time: 5.1 sec

How does @SpringBootTest work?

@SpringBootTest loads the full application context. All beans, all configurations, the web server (if webEnvironment = RANDOM_PORT). It is slow, so reserve it for integration tests that must exercise multiple layers together.

JAVA
package com.bank;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class TransferIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void transferReducesSourceBalance() {
        // Full integration: HTTP -> Controller -> Service -> Repository -> DB
        ResponseEntity<String> response = restTemplate.postForEntity(
            "/api/v1/transfers",
            new TransferRequest("SAV2001", "SAV2002", new BigDecimal("10000")),
            String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

What are common mistakes in testing?

The most common mistake is using @SpringBootTest for everything. Loading the full context for a simple business logic test wastes time and makes the test suite slow, discouraging developers from running tests frequently.

JAVA
// WRONG: full context for a simple calculation test
@SpringBootTest
class InterestCalculatorTest {
    @Autowired
    private InterestCalculator calculator;

    @Test
    void calculatesCorrectly() {
        // Loads entire app context (3+ seconds) for a 5ms calculation
    }
}

// CORRECT: no Spring context, Mockito only
@ExtendWith(MockitoExtension.class)
class InterestCalculatorTest {
    @Mock private InterestRateRepository rateRepo;
    @InjectMocks private InterestCalculator calculator;

    @Test
    void calculatesCorrectly() {
        // Runs in 5ms, no Spring overhead
    }
}

Another mistake is not testing failure paths. Tests that only verify the happy path miss the bugs that appear in production.

What should a senior engineer know?

  • TDD (Test-Driven Development): write a failing test first (Red), write the minimum code to pass (Green), then clean up the design (Refactor). This guarantees every line of production code is covered and designed to be testable.
  • JaCoCo measures code coverage (line + branch). High coverage means nothing if assertions are weak. Coverage shows what ran, not whether the assertions are meaningful.
  • Use @Nested classes in JUnit 5 to group related tests and share setup within a test class.
  • Pankaj at National Bank of India uses a shared Testcontainers base class that starts PostgreSQL once and reuses it across all repository tests, reducing total test time.
JAVA
package com.bank;

import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;

public abstract class BaseIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

// All repository tests extend this base class
// PostgreSQL container starts once and is reused

What are common interview follow-up questions?

  • When should you use Testcontainers vs H2? Use Testcontainers when you need to test PostgreSQL-specific features: JSON columns, array types, native queries, unique constraints, and Flyway migrations. Use H2 only for quick prototyping.
  • What is the difference between @MockBean and @Mock? @MockBean registers the mock in the Spring ApplicationContext, replacing the real bean. @Mock creates a mock outside Spring. Use @MockBean with @WebMvcTest and @SpringBootTest; use @Mock with plain Mockito unit tests.
  • How do you test async methods? Use Awaitility.await().atMost(5, SECONDS).until(() -> ...) to poll for the expected result.
  • What is BDD-style testing? BDD (Behavior-Driven Development) uses given/when/then naming. Mockito supports this with BDDMockito.given(...).willReturn(...).

Visual Overview

DIAGRAM
  Testing Pyramid:

         /\          E2E Tests
        /  \         (few, slow)
       /    \
      / Integ \
     / ration  \     @SpringBootTest
    /   Tests   \
   /─────────────\   @WebMvcTest, @DataJpaTest
  /  Slice Tests  \
 /─────────────────\
/    Unit Tests     \   Mockito, no Spring
─────────────────────
   MANY, FAST, CHEAP


  Test Annotation Scope:

  ┌──────────────────────────────────────────────┐
  │            @SpringBootTest                    │
  │  ┌────────────────────────────────────────┐  │
  │  │         @WebMvcTest                     │  │
  │  │  Controller + MockMvc + Jackson         │  │
  │  │  Services mocked via @MockBean          │  │
  │  └────────────────────────────────────────┘  │
  │  ┌────────────────────────────────────────┐  │
  │  │         @DataJpaTest                    │  │
  │  │  Entity + Repository + EntityManager    │  │
  │  │  Testcontainers for real PostgreSQL     │  │
  │  └────────────────────────────────────────┘  │
  │  Service layer, Config, Security, etc.       │
  └──────────────────────────────────────────────┘

  ┌──────────────────────────────────────────────┐
  │  Unit Test (no Spring)                        │
  │  @ExtendWith(MockitoExtension.class)          │
  │  @Mock + @InjectMocks                         │
  │  Pure Java, milliseconds                      │
  └──────────────────────────────────────────────┘

Key Takeaway

Testing in Spring Boot follows the testing pyramid. Unit tests with Mockito and no Spring context run in milliseconds and form the base. Rajesh writes them for every service class at National Bank of India using @Mock for dependencies, @InjectMocks for the subject, ArgumentCaptor for inspecting saved objects, and assertThatThrownBy for exception assertions. @WebMvcTest loads only the controller layer with MockMvc and mocked services, testing HTTP status codes, JSON structure, and validation without a database. @DataJpaTest loads only the JPA layer with Testcontainers providing a real PostgreSQL in Docker, testing queries, constraints, and migrations with the actual database dialect.

@SpringBootTest loads the full context for end-to-end integration tests and is reserved for scenarios that must exercise multiple layers together. Tests must cover failure paths equally: invalid input, missing data, duplicate records, unauthorized access, and timeouts. A transfer test that only checks the happy path will miss the bug where a negative amount passes validation.

Pankaj structures the test suite with a shared Testcontainers base class so PostgreSQL starts once and is reused across all repository tests. JaCoCo reports code coverage, but Pankaj reminds the team that coverage shows what ran, not whether the assertions are meaningful. A test with 100 percent coverage but no assertions proves nothing.

Remember one sentence: unit tests with Mockito for speed, @WebMvcTest for controllers, @DataJpaTest with Testcontainers for queries, and @SpringBootTest only when you need the full context.

---

Share