Skip to content
Commitwise
Menu
Java Backend

Testing with JUnit, Mockito and Testcontainers

Test a Spring Boot backend at the right level, from fast unit tests with mocks to web-layer slices with MockMvc and integration tests against a real PostgreSQL in Testcontainers, in Java and Kotlin, and avoid tests that pass while production fails.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The test suite has 600 green tests, and the release still broke checkout: the query used a PostgreSQL feature the in-memory test database didn’t have, the endpoint returned the wrong status code that no test called, and half the “tests” checked that a mock returned what it was told to return. Backend testing is mostly about choosing the right level for each check, and making sure each test touches the thing that could actually break.

New to some of these? Open what you need, or skip ahead.

Basics:What are unit tests and fakes?

A unit test is a small piece of code that checks one piece of your app automatically: call a function with known input, and compare the result with what you expect. Tests run in seconds, so you run them after every change.

A fake is a simple stand-in for something slow or risky, like a payment provider or a server. It returns prepared answers instead of charging a card, so the test is fast, repeatable and safe.

Basics:What is Spring Boot?

Spring Boot is the most popular framework for Java and Kotlin backends. You write classes and mark them with annotations; Spring creates the objects (called beans), connects them to each other (dependency injection: a controller gets the service it needs through its constructor) and runs a web server that sends HTTP requests to your methods.

Starters (spring-boot-starter-web, spring-boot-starter-data-jpa…) add features by adding a dependency, with sensible defaults you can change in application.yml.

Basics:What is Docker?

Docker runs programs in containers: lightweight, isolated packages that include everything the program needs. A test can start a real PostgreSQL database in a container in a few seconds, use it, and throw it away, so every test run starts from the same clean state on every machine.

Part Not started

Unit tests with mocks

To test a car’s dashboard, you don’t need a running engine: you connect the dashboard to a box that sends “speed: 80” and check that the needle points at 80. The dashboard is real; everything around it is simulated.

A unit test does that for one class. The class under test is real; its dependencies (the repository, the payment client) are replaced with mocks: objects you program to return specific answers, and can ask afterwards what they were called with. No database, no network, milliseconds per test.

Unit tests target business logic in services and domain objects, with collaborators at I/O boundaries replaced by test doubles. Prefer state-based assertions on the result; verify interactions only when the interaction is the behavior (a charge was requested, an event was published). Over-mocked tests (mocking value objects, mapping helpers, or the class’s own collaborators in the same module) couple tests to implementation and break on every refactor while catching nothing.

class DiscountServiceTest {
    private val orders: OrderRepository = mock()                            // mockito-kotlin
    private val service = DiscountService(orders)                           // the real class under test

    @Test
    fun `loyal customers get ten percent`() {
        whenever(orders.countByCustomerId("ana")).thenReturn(12L)           // stub what it needs

        val percent = service.discountFor("ana")

        assertThat(percent).isEqualTo(10)
    }

    @Test
    fun `new customers get nothing`() {
        whenever(orders.countByCustomerId("ben")).thenReturn(0L)
        assertThat(service.discountFor("ben")).isZero()
    }
}
class DiscountServiceTest {
    private final OrderRepository orders = mock(OrderRepository.class);
    private final DiscountService service = new DiscountService(orders);   // the real class under test

    @Test
    void loyalCustomersGetTenPercent() {
        when(orders.countByCustomerId("ana")).thenReturn(12L);              // stub what it needs

        int percent = service.discountFor("ana");

        assertThat(percent).isEqualTo(10);                                  // AssertJ
    }

    @Test
    void newCustomersGetNothing() {
        when(orders.countByCustomerId("ben")).thenReturn(0L);
        assertThat(service.discountFor("ben")).isZero();
    }
}

The service is created with new (no Spring at all), so the test starts instantly. The repository is a mock: when(...).thenReturn(...) decides what it answers. The test then calls the real discountFor and checks its answer. Kotlin developers often use MockK instead of Mockito (every { orders.countByCustomerId("ana") } returns 12); the idea is the same.

Spot the bugA test that tests nothing

Tap the line with the bug.

@Test
fun findsCustomerByEmail() {
    val ana = Customer("c-1", "ana@example.com")
    whenever(customers.findByEmail("ana@example.com")).thenReturn(Optional.of(ana))

    val result = customers.findByEmail("ana@example.com")

    assertThat(result).contains(ana)
}
@Test
void findsCustomerByEmail() {
    var ana = new Customer("c-1", "ana@example.com");
    when(customers.findByEmail("ana@example.com")).thenReturn(Optional.of(ana));

    Optional<Customer> result = customers.findByEmail("ana@example.com");

    assertThat(result).contains(ana);
}

Practice: when to verify a call

  1. If the method returns something, assert on the result. That’s usually enough.
  2. If the method’s job is to cause something elsewhere (charge a card, send an email, publish an event), verify that call: verify(payments).charge("c-1", 1999).
  3. Check what didn’t happen when that matters: verifyNoInteractions(payments) after a declined order.
  4. Don’t verify every internal call (verify(repo).findById(...)): the result already proves it, and the test breaks when the implementation changes for no good reason.

Choosing doubles

  • Fakes over mocks for repositories and clients you use in many tests: an in-memory FakeOrderRepository with real behavior (save then find) makes tests readable and refactor-proof.
  • Don’t mock what you don’t own at its raw API (Jackson, RestClient, JPA EntityManager): wrap it in your own port and fake the port, or test the adapter for real (WireMock for HTTP, Testcontainers for databases).
  • Argument matchers and captors (ArgumentCaptor, MockK slot) for asserting on complex outgoing objects; prefer asserting on fields over full equals.
  • Strict stubs (Mockito’s default with MockitoExtension) fail on unused stubs, which catches tests whose setup no longer matches the code.
  • Unit tests: real class under test, doubles at I/O boundaries, state-based assertions; verify interactions only when they are the behavior.
  • Prefer fakes for widely used ports; don’t mock third-party APIs directly; WireMock/Testcontainers for adapters.
  • Mockito strict stubs (MockitoExtension), ArgumentCaptor; Kotlin: mockito-kotlin or MockK (every/verify/slot).

Check this part

  1. In a unit test of DiscountService, which object is real?

  2. A test stubs customers.findByEmail and then calls customers.findByEmail. What does it check?

  3. When should a test verify a call on a mock?

Unit tests check one class's logic in milliseconds, with its dependencies replaced by mocks or fakes (Mockito in Java, mockito-kotlin or MockK in Kotlin). Call the real class under test, stub only what it needs, and assert on its result or on the interactions that are its job.

Part Not started

Testing the web layer

Some bugs only exist where your code meets Spring: a wrong URL, a missing @Valid, a security rule in the wrong order, a JSON field with the wrong name. A unit test that calls the controller method directly misses all of them. A slice test starts just the web part of Spring and sends real HTTP-shaped requests through it, without starting a server or a database.

@WebMvcTest builds a context with MVC infrastructure only (controllers, @ControllerAdvice, converters, filters including Spring Security) and a MockMvc that dispatches requests through the full DispatcherServlet pipeline. It’s the right level for request mapping, (de)serialization, validation, error mapping and authorization rules. Collaborators are replaced with @MockitoBean (Boot 3.4+, replacing @MockBean). Every distinct set of mocked beans creates a new cached context, so keep slices consistent to keep the suite fast.

@WebMvcTest(OrderController::class)
@Import(SecurityConfig::class)
class OrderControllerTest(@Autowired private val mvc: MockMvc) {
    @MockitoBean lateinit var orders: OrderService

    @Test
    fun `returns the order`() {
        whenever(orders.find(42L, "user-1")).thenReturn(Optional.of(OrderDto(42L, "PAID", 1999)))

        mvc.get("/api/orders/42") { with(jwt().jwt { it.subject("user-1") }) }
            .andExpect {
                status { isOk() }
                jsonPath("$.status") { value("PAID") }
                jsonPath("$.totalCents") { value(1999) }
            }
    }

    @Test
    fun `anonymous callers get 401`() {
        mvc.get("/api/orders/42").andExpect { status { isUnauthorized() } }
    }

    @Test
    fun `invalid orders are rejected`() {
        mvc.post("/api/orders") {
            with(jwt().authorities(SimpleGrantedAuthority("SCOPE_orders:write")))
            contentType = MediaType.APPLICATION_JSON
            content = """{ "items": [] }"""
        }.andExpect { status { isBadRequest() } }
    }
}
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerTest {
    @Autowired MockMvc mvc;
    @MockitoBean OrderService orders;

    @Test
    void returnsTheOrder() throws Exception {
        when(orders.find(42L, "user-1")).thenReturn(Optional.of(new OrderDto(42L, "PAID", 1999)));

        mvc.perform(get("/api/orders/42").with(jwt().jwt(j -> j.subject("user-1"))))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.status").value("PAID"))
           .andExpect(jsonPath("$.totalCents").value(1999));
    }

    @Test
    void anonymousCallersGet401() throws Exception {
        mvc.perform(get("/api/orders/42")).andExpect(status().isUnauthorized());
    }

    @Test
    void invalidOrdersAreRejected() throws Exception {
        mvc.perform(post("/api/orders")
                .with(jwt().authorities(new SimpleGrantedAuthority("SCOPE_orders:write")))
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    { "items": [] }
                    """))
           .andExpect(status().isBadRequest());
    }
}

What this checks that a unit test can’t: the URL and HTTP method, the JSON field names, that @Valid actually rejects an empty order with 400, and that an anonymous request gets 401. jwt() creates a fake, already-validated token for the test, so no login server is needed. The service is mocked: this test is about the web layer, and the service has its own tests.

Predict the output

A @WebMvcTest(OrderController.class) test doesn't declare a @MockitoBean for OrderService, which the controller needs. What happens?

Exerciseeasy

Test the error responses

Write two @WebMvcTest tests for GET /api/orders/{id}: an order that doesn’t exist (or belongs to someone else) returns 404, and a non-numeric id (/api/orders/abc) returns 400.

›

Stub the service to return Optional.empty() for the first test. The second needs no stubbing: Spring fails to convert abc to a long before your controller runs.

SolutionTry it yourself first
@Test
fun `unknown order is 404`() {
    whenever(orders.find(99L, "user-1")).thenReturn(Optional.empty())

    mvc.get("/api/orders/99") { with(jwt().jwt { it.subject("user-1") }) }
        .andExpect { status { isNotFound() } }
}

@Test
fun `non-numeric id is 400`() {
    mvc.get("/api/orders/abc") { with(jwt()) }
        .andExpect { status { isBadRequest() } }
    verifyNoInteractions(orders)                        // the service was never reached
}
@Test
void unknownOrderIs404() throws Exception {
    when(orders.find(99L, "user-1")).thenReturn(Optional.empty());

    mvc.perform(get("/api/orders/99").with(jwt().jwt(j -> j.subject("user-1"))))
       .andExpect(status().isNotFound());
}

@Test
void nonNumericIdIs400() throws Exception {
    mvc.perform(get("/api/orders/abc").with(jwt()))
       .andExpect(status().isBadRequest());
    verifyNoInteractions(orders);                       // the service was never reached
}

Error paths are where APIs most often disagree with their documentation: a 500 instead of a 400, a 200 with an empty body instead of a 404. A slice test per error case is cheap and catches them.

Keeping slices fast

  • Spring caches application contexts by configuration; each unique combination of @MockitoBeans, properties and imports is a new context. A suite with 40 slightly different web tests can start 40 contexts. Standardize slices (shared test configurations, the same mocked beans) and watch the context count in logs.
  • @DataJpaTest loads JPA only, wraps each test in a rolled-back transaction, and by default replaces your DataSource with an embedded database; disable that (@AutoConfigureTestDatabase(replace = NONE)) and point it at Testcontainers, for the reasons in the next part.
  • MockMvcTester (Boot 3.4+) gives AssertJ-style assertions on MockMvc results, if you prefer them to andExpect.
  • @WebMvcTest: MVC + security slice with MockMvc; test mapping, JSON, validation, error mapping and authorization. Collaborators via @MockitoBean (Boot 3.4+). jwt() / @WithMockUser for principals.
  • @DataJpaTest: JPA slice, transactional per test; disable embedded DB replacement for real databases.
  • Context caching: consistent slice configurations keep the suite fast; each unique mock set is a new context.

Check this part

  1. What does @WebMvcTest load?

  2. Which bug can a @WebMvcTest catch that a plain unit test of the controller method can't?

  3. Why can 40 web tests with slightly different @MockitoBean sets make the suite slow?

Slice tests start only part of Spring: @WebMvcTest loads controllers, JSON and security with MockMvc, so you test URLs, status codes, validation and access rules without a server or database. Services are replaced with @MockitoBean. @DataJpaTest does the same for repositories.

Part Not started

Integration tests with Testcontainers

A flight simulator is great for practice, but before a new plane carries passengers, someone flies the real one. Mocks and in-memory databases are simulators: they behave like PostgreSQL most of the time, and differently exactly where your bug is.

Testcontainers starts a real PostgreSQL (or Redis, or Kafka) in a Docker container when the tests start, connects your app to it, and removes it at the end. The tests run against exactly the database you use in production.

Integration tests cover what unit and slice tests can’t: SQL and dialect specifics (jsonb, ON CONFLICT, ILIKE, window functions), Flyway/Liquibase migrations, JPA mappings and fetch plans, transactions and locking, and the wiring of the full context. Testcontainers makes them reproducible locally and in CI; @ServiceConnection (Boot 3.1+) wires the container’s connection details without manual properties.

@SpringBootTest
@Testcontainers
class OrderRepositoryIT(@Autowired private val orders: OrderRepository) {

    companion object {
        @Container
        @ServiceConnection                                   // Spring connects to this container
        @JvmStatic
        val postgres = PostgreSQLContainer("postgres:17")
    }

    @Test
    fun `search is case insensitive`() {
        orders.save(Order("o-1", "c-1", "Winter Jacket"))
        orders.save(Order("o-2", "c-1", "jacket liner"))

        val found = orders.searchByTitle("JACKET")           // a native query using ILIKE

        assertThat(found).extracting<String> { it.id }.containsExactlyInAnyOrder("o-1", "o-2")
    }
}
@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

    @Container
    @ServiceConnection                                       // Spring connects to this container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");

    @Autowired OrderRepository orders;

    @Test
    void searchIsCaseInsensitiveAndUsesTheIndex() {
        orders.save(new Order("o-1", "c-1", "Winter Jacket"));
        orders.save(new Order("o-2", "c-1", "jacket liner"));

        var found = orders.searchByTitle("JACKET");          // a native query using ILIKE

        assertThat(found).extracting(Order::id).containsExactlyInAnyOrder("o-1", "o-2");
    }
}

When the test class starts, Testcontainers pulls the postgres:17 image (once, then cached), starts it on a free port, and @ServiceConnection points Spring’s DataSource at it. Your Flyway migrations run against it like in production. If the query uses PostgreSQL-only syntax like ILIKE, this test proves it works, where an in-memory H2 database would either fail or behave differently.

Dev note
Testcontainers 2.x reorganized some modules and packages (for example the PostgreSQL module’s package); the annotations and the setup stay the same.

Production puzzles

Predict the output

A suite has 40 integration test classes. Each declares its own static @Container PostgreSQLContainer, and each slightly varies @MockitoBean declarations. Starting Postgres takes ~3 s and a Spring context ~4 s. Roughly how much startup overhead does the suite pay?

Spot the bugTests pass, production throws LazyInitializationException

Tap the line with the bug.

@SpringBootTest
@Transactional
class OrderApiIT(
    @Autowired private val controller: OrderController,
    @Autowired private val orders: OrderRepository,
) {
    @Test
    fun returnsOrderWithItems() {
        orders.save(orderWithTwoItems("o-1", "c-1"))
        val dto = controller.order("o-1", jwtFor("c-1"))
        assertThat(dto.items).hasSize(2)
    }
}
@SpringBootTest
@Transactional
class OrderApiIT {
    @Autowired OrderController controller;
    @Autowired OrderRepository orders;

    @Test
    void returnsOrderWithItems() {
        orders.save(orderWithTwoItems("o-1", "c-1"));
        OrderDto dto = controller.order("o-1", jwtFor("c-1"));
        assertThat(dto.items()).hasSize(2);
    }
}

Exercisemedium

A test strategy for an order service

An order service has pricing rules, a REST API with security, PostgreSQL with Flyway, a Kafka producer for OrderPlaced, and calls a payment provider over HTTP. The current suite is 900 @SpringBootTest tests taking 25 minutes, flaky on CI. Propose a test strategy: what’s tested at which level, with which tools, and how the suite stays under 5 minutes.

›

Push each check to the cheapest level that can actually catch its bug. Which bugs need a real database? A real broker? Which need nothing but a function call?

SolutionTry it yourself first
LevelWhatToolsShare
UnitPricing rules, validation, state transitions, mappersJUnit 5, AssertJ, fakes/mocks, parameterized testsMost tests; milliseconds each
Web sliceURLs, JSON contracts, validation (400), errors (404/409), security rules per role@WebMvcTest, MockMvc, jwt()One per endpoint × case
PersistenceRepositories, native queries, migrations, fetch plans, constraints@DataJpaTest + Testcontainers PostgreSQL (shared container)Every custom query
AdaptersPayment HTTP client (timeouts, errors, idempotency headers), Kafka producer serializationWireMock; Testcontainers KafkaPer adapter
End to end in-processA few critical flows: place order → paid → event published@SpringBootTest(RANDOM_PORT) + all containers, no test transactionsA handful

Speed and stability:

  • One container per dependency per run (singleton pattern or @ImportTestcontainers on a shared config), consistent test configurations to reuse Spring contexts, parallel test classes where state is isolated.
  • No shared mutable state: unique ids per test, cleanup after each, no ordering assumptions; Awaitility for async assertions instead of Thread.sleep.
  • No @Transactional on integration tests that exercise transaction boundaries.
  • Measure: report the slowest tests and the number of contexts started; fail the build if contexts exceed a threshold.
  • Testcontainers + @ServiceConnection (Boot 3.1+) for real PostgreSQL/Kafka/Redis; prefer it to H2, which hides dialect, migration and locking behavior.
  • One container per run (singleton/@ImportTestcontainers, reuse locally) and consistent configurations for context caching.
  • Avoid class-level @Transactional on integration tests: it hides lazy-loading, commit-time constraints and after-commit listeners; use RANDOM_PORT + real HTTP + explicit cleanup.
  • Strategy: most checks as unit tests, contracts and security as slices, queries and adapters with real dependencies, few end-to-end flows.

Check this part

  1. Why test repositories against PostgreSQL in Testcontainers instead of an in-memory H2 database?

  2. What does @ServiceConnection do?

  3. Why can @Transactional on an integration test class hide a LazyInitializationException?

Integration tests run against the real database (PostgreSQL in a Testcontainers container, connected with @ServiceConnection) to catch SQL, migration and mapping bugs that mocks and in-memory databases hide. Share one container per test run and don't let test transactions hide production behavior.

Key takeaways

  • Unit tests call the real class under test with mocked dependencies; never test the mock itself.
  • @WebMvcTest with MockMvc checks URLs, JSON, validation, status codes and security rules.
  • Testcontainers runs a real PostgreSQL for repository and integration tests.
  • Pick the cheapest level that can catch each bug: most tests are unit tests, a few run everything.
  • State-based unit tests with fakes; verify interactions only when they are the behavior.
  • Slices for contracts and authorization; keep configurations consistent for context caching.
  • Real dependencies via Testcontainers for queries, migrations and adapters; one container per run.
  • Don’t let test transactions or direct method calls hide production behavior.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.