Definition
- 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 |
/\ 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.
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");
}
}
$ 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 |
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.
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.
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.
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"))));
}
}
$ 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.
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.
// 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
@Nestedclasses 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.
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
@MockBeanand@Mock?@MockBeanregisters the mock in the Spring ApplicationContext, replacing the real bean.@Mockcreates a mock outside Spring. Use@MockBeanwith@WebMvcTestand@SpringBootTest; use@Mockwith 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/thennaming. Mockito supports this withBDDMockito.given(...).willReturn(...).
Visual Overview
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.
---