Skip to content

Java Testing with JUnit 5 and Mockito: A Production Guide

Picture 94 percent line coverage, every test green — and customers double-charged hours after launch. The payment integration's tests mock the HTTP client to always return 200. The real gateway returns 202 (accepted, still processing)⁠[1] for a fraction of transactions, and the handler treats 202 as failure and retries — the classic shape of a "high-coverage, low-confidence" suite.

Coverage percentage measures which lines the JVM executed, not whether tests catch real bugs. JUnit 5 and Mockito are the lifecycle and mocking primitives⁠[2]⁠[3]; WireMock covers the HTTP boundary and Testcontainers covers the database — the two surfaces a mocked-only suite never reaches. Match the test type to what you're actually verifying:

The test pyramid: choosing the right tool

WhatTest TypeKey AnnotationsWhat It Catches
Business logicUnit@ExtendWith(MockitoExtension.class)Logic errors, null handling
Controller HTTPSlice@WebMvcTestStatus codes, serialization, validation
JPQL queriesSlice@DataJpaTestBroken queries, missing mappings
Native SQLIntegration@TestcontainersDialect-specific SQL, migrations
External APIsIntegration@WireMockTestTimeouts, retries, response parsing

Never write a @SpringBootTest to verify that 2 + 2 == 4 — push tests down the list only as far as the thing you're verifying requires.

Unit tests: structure and parameterization

JUnit 5 discovers @Test methods automatically. Organize with Given-When-Then: Given (setup), When (call the method), Then (assert).⁠[2]

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
 
class PricingServiceTest {
 
    private PricingService pricing = new PricingService();
 
    @Test
    @DisplayName("applies 10% discount for orders over $100")
    void appliesDiscountForLargeOrders() {
        // Given
        var order = new Order("SKU-001", 5, 25.00); // $125 total
 
        // When
        double total = pricing.calculateTotal(order);
 
        // Then — $125 - 10% = $112.50
        assertEquals(112.50, total, 0.01);
    }
}

Replace copy-pasted test methods with @ParameterizedTest:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
 
class PricingServiceTest {
 
    @ParameterizedTest(name = "{0} items at ${1} = ${2}")
    @CsvSource({
        "5, 25.00, 112.50",   // bulk: 10% off
        "2, 25.00, 50.00",    // under threshold
        "20, 10.00, 170.00"   // bulk: 15% off
    })
    void calculatesDiscountCorrectly(int qty, double price, double expected) {
        var order = new Order("SKU-001", qty, price);
        assertEquals(expected, pricing.calculateTotal(order), 0.01);
    }
}

When a case fails, JUnit reports which input: calculatesDiscountCorrectly[1] 2 items at $25.00 = $50.00 FAILED. No guessing which test broke.

Choose the right test type by what you're verifying

The decision tree for "which Java test annotation should I use" — route by what is under test, not by familiarity:

graph LR
    Code[I want to test...] --> What{What is<br/>under test?}
    What -->|Pure business logic<br/>no Spring context| Unit[JUnit 5 plain<br/>+ MockitoExtension<br/>5-50 ms each]
    What -->|Controller HTTP<br/>status, validation| MVC["@WebMvcTest<br/>load only MVC layer<br/>200-500 ms each"]
    What -->|JPQL queries<br/>repository methods| JPA["@DataJpaTest<br/>load only persistence<br/>+ in-memory H2"]
    What -->|Native SQL<br/>migrations, dialect| TC["@Testcontainers<br/>+ PostgreSQLContainer<br/>5-15 s each"]
    What -->|External HTTP boundary<br/>retries, timeouts| Wire["@WireMockTest<br/>simulate 500s, slow,<br/>malformed JSON"]
    What -->|Whole app<br/>cross-layer flow| Boot["@SpringBootTest<br/>last resort<br/>10-30 s each"]
    Unit -->|Confidence<br/>vs cost| Pyramid[Test pyramid<br/>thousands of unit<br/>hundreds of slice<br/>tens of integration]
    MVC --> Pyramid
    JPA --> Pyramid
    TC --> Pyramid
    Wire --> Pyramid
    Boot --> Pyramid
    style Unit fill:#dfd
    style MVC fill:#dfd
    style JPA fill:#ffd
    style TC fill:#ffd
    style Wire fill:#ffd
    style Boot fill:#fdd

The diagram is the entire pyramid in one picture, one layer more detailed than the table above.

Mocking with Mockito

Mockito replaces Java interfaces with controllable stand-ins. Use it to isolate the unit under test from its dependencies—not to replace infrastructure.⁠[3]

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
 
@ExtendWith(MockitoExtension.class)
class InventoryServiceTest {
 
    @Mock private ProductRepository productRepo;
    @Mock private WarehouseClient warehouseClient;
    @InjectMocks private InventoryService inventoryService;
 
    @Test
    void reservesStockWhenAvailable() {
        // Given
        var product = new Product("SKU-42", "Widget", 100);
        when(productRepo.findBySku("SKU-42"))
            .thenReturn(Optional.of(product));
        when(warehouseClient.reserve("SKU-42", 5))
            .thenReturn(true);
 
        // When
        var result = inventoryService.reserveStock("SKU-42", 5);
 
        // Then
        assertTrue(result.isReserved());
        verify(warehouseClient).reserve("SKU-42", 5);
    }
 
    @Test
    void throwsWhenProductNotFound() {
        when(productRepo.findBySku("MISSING"))
            .thenReturn(Optional.empty());
 
        assertThrows(ProductNotFoundException.class,
            () -> inventoryService.reserveStock("MISSING", 1));
        
        // Assert the warehouse was never called
        verifyNoInteractions(warehouseClient);
    }
}

@ExtendWith(MockitoExtension.class) initializes @Mock and @InjectMocks annotations. @InjectMocks calls the constructor with mocked dependencies. verify() confirms a method was called; verifyNoInteractions() confirms the mock was never touched—useful for asserting that early-return validation prevented a downstream call.

Spring Boot slices: testing layers independently

The slice annotations carve specific layers out of the Spring context — load only what you test, mock the rest:

graph LR
    Slice{Spring slice<br/>annotation?} -->|"@WebMvcTest"| MVC[MVC layer only<br/>controllers, filters,<br/>exception handlers<br/>+ MockMvc<br/>200-500 ms]
    Slice -->|"@DataJpaTest"| JPA[Persistence only<br/>repos, entities,<br/>JPA config<br/>+ in-memory H2<br/>500 ms-1 s]
    Slice -->|"@JdbcTest"| JDBC["JdbcTemplate +<br/>@Sql scripts<br/>no JPA overhead<br/>200-500 ms"]
    Slice -->|"@JsonTest"| JSON[Jackson config only<br/>+ JacksonTester<br/>50-100 ms]
    Slice -->|"@RestClientTest"| Rest[RestTemplate +<br/>WebClient config<br/>+ MockRestServiceServer<br/>200-500 ms]
    Slice -->|"@SpringBootTest"| Full[FULL context<br/>last resort<br/>5-30 s]
    MVC -->|inject| Mock["@MockitoBean / @MockBean<br/>for collaborators"]
    JPA -->|inject| Mock
    JDBC -->|inject| Mock
    Full -->|requires real infra| TC[+ Testcontainers<br/>for Postgres/Kafka/Redis]
    style MVC fill:#dfd
    style JPA fill:#dfd
    style JSON fill:#dfd
    style Full fill:#fdd

@WebMvcTest loads only the web layer: controllers, filters, exception handlers. No service beans, no database. You provide mocks.

import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
 
@WebMvcTest(OrderController.class)
class OrderControllerTest {
 
    @Autowired private MockMvc mockMvc;
    @MockitoBean private OrderService orderService;
 
    @Test
    void returnsOrderById() throws Exception {
        var order = new OrderDTO("ord_123", "Widget", 3, 89.97);
        when(orderService.findById("ord_123"))
            .thenReturn(Optional.of(order));
 
        mockMvc.perform(get("/api/orders/ord_123"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value("ord_123"));
    }
}

Starts in <2 seconds. Catches JSON serialization bugs, missing @RequestBody annotations, wrong HTTP status codes from exception handlers.

@DataJpaTest loads JPA, repositories, migrations, and an embedded database. Rolls back each test automatically—no cleanup needed.

@DataJpaTest
class OrderRepositoryTest {
 
    @Autowired private TestEntityManager em;
    @Autowired private OrderRepository orderRepo;
 
    @Test
    void findsPendingOrdersOlderThan() {
        em.persist(new OrderEntity("ord_1", Status.PENDING,
            Instant.now().minus(48, ChronoUnit.HOURS)));
        em.flush();
 
        var results = orderRepo.findPendingOlderThan(
            Instant.now().minus(24, ChronoUnit.HOURS));
        assertEquals(1, results.size());
    }
}

Uses H2 by default — catches most JPQL bugs but misses PostgreSQL-specific behavior: jsonb, ON CONFLICT, advisory locks. Use Testcontainers for those.

@WebMvcTest loads only the web layer — the controllers, filters, and exception handlers you build in a Spring Boot REST API. It does not start the full application context, the database, or any service beans. You provide those as mocks.

WireMock: testing HTTP boundaries

Mockito mocks Java interfaces. WireMock spins up a real HTTP server that returns whatever you configure—including timeouts, 500 errors, malformed JSON. This catches bugs that Mockito can't: incorrect request headers, wrong content types, timeout handling, retry behavior on specific status codes.

import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
 
@SpringBootTest
@WireMockTest(httpPort = 8089)
class PaymentGatewayTest {
 
    @Autowired private PaymentService paymentService;
 
    @Test
    void completesPaymentOnHttp200() {
        stubFor(post(urlPathEqualTo("/v1/charges"))
            .withRequestBody(matchingJsonPath("$.amount"))
            .willReturn(okJson("""
                { "id": "ch_abc123", "status": "completed", "amount": 8997 }
                """)));
 
        var result = paymentService.charge("ord_123", 89.97);
        assertTrue(result.isCompleted());
        assertEquals("ch_abc123", result.transactionId());
    }
 
    @Test
    void handlesAcceptedStatusWithoutDoubleCharging() {
        // Gateway returns 202 (processing), not 200 (completed)
        stubFor(post(urlPathEqualTo("/v1/charges"))
            .willReturn(aResponse()
                .withStatus(202)
                .withBody("""
                    { "id": "ch_pending", "status": "processing", "amount": 8997 }
                    """)));
 
        var result = paymentService.charge("ord_789", 89.97);
        assertFalse(result.isCompleted()); // should NOT retry
        assertTrue(result.isPending());
        verify(1, postRequestedFor(urlPathEqualTo("/v1/charges")));
    }
}

This tests the exact scenario from this article's opening: the real gateway returns 202 for a fraction of transactions, which a mocked paymentClient never would.

Testcontainers: testing with real infrastructure

Testcontainers spins up real infrastructure (Docker) for each test class. Tests run against the same database, broker, and cache that production uses—not H2 or mocks.

@SpringBootTest
@Testcontainers
class OrderIntegrationTest {
 
    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
 
    @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 OrderRepository orderRepo;
    @Autowired private OrderService orderService;
 
    @Test
    void uniqueConstraintPreventsDoubleSubmission() {
        var order = new CreateOrderRequest("SKU-42", 1, "idem_key_abc");
        orderService.placeOrder(order); // first call succeeds
 
        assertThrows(DuplicateOrderException.class,
            () -> orderService.placeOrder(order)); // same idempotency key
    }
 
    @Test
    void nativeQueryWithJsonbWorks() {
        orderRepo.save(new OrderEntity("ord_1", Status.PENDING,
            Map.of("priority", "high", "source", "api")));
 
        var results = orderRepo.findByMetadataKey("priority", "high");
        assertEquals(1, results.size());
    }
}

What Testcontainers catches that H2 and mocks miss: unique constraint violations with PostgreSQL's exact conflict resolution, native SQL using jsonb or window functions, Kafka serialization issues, Redis Lua script atomicity, transaction isolation differences between H2 and PostgreSQL.

Testcontainers adds a few seconds per test class for container startup, but it exercises the integration bugs that mocked dependencies structurally cannot — schema migrations, native query syntax, transaction isolation behavior — the class of bug that mocks and in-memory databases never reach. It's the difference between "works on H2, breaks on Postgres" shipping to production and getting caught in CI.

Production checklist

Before shipping test code, verify:

  • Treat 80% coverage on critical paths as a practical floor, not a target (payments, auth, business logic) — and don't trust the number alone; run mutation testing
  • No test depends on test execution order. Set junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random and junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$Random in CI
  • No shared mutable state between tests — use @BeforeEach not @BeforeAll for setup
  • Flaky tests are quarantined — tag with @Tag("flaky") and exclude from main pipeline until fixed
  • Parallel execution enabled. Configure junit-platform.properties: junit.jupiter.execution.parallel.enabled=true
  • No @SpringBootTest for logic tests — use unit tests with Mockito for milliseconds, slice tests for seconds, integration tests for problem scenarios
  • WireMock covers retry paths, timeouts, error codes — not just the happy path
  • Testcontainers used for databases and brokers — not mocks or H2

Run locally before committing:

mvn test                                                   # unit + slice tests (Surefire)
mvn verify                                                 # adds integration tests if Failsafe is configured
mvn test-compile org.pitest:pitest-maven:mutationCoverage  # mutation score (nightly on critical paths)

Diagnosing flaky tests

Most flaky tests fall into one of five categories:

  1. Shared mutable state between tests — use @BeforeEach not @BeforeAll; instance fields not static
  2. Time-dependent assertions — inject a Clock and use fixed times in tests; don't assert on Instant.now()
  3. Port conflicts — always use dynamic ports via @SpringBootTest(webEnvironment = RANDOM_PORT), never hardcode 8080
  4. Test ordering assumptions — randomize method and class order in CI (checklist above)
  5. Async race conditions — use await().atMost(5, SECONDS).untilAsserted(...) instead of immediate assertions

Quarantine flaky tests with @Tag("flaky") and exclude from main CI until fixed. A flaky test points to a real bug — in the test's isolation or in the production code.

Mutation testing: validate your assertions

Code coverage measures which lines the JVM executed. Mutation testing tells you whether your tests would catch a bug. PIT (Pitest) introduces small changes to production code—flipping conditionals, removing method calls—and checks if tests detect the change.

Mutation score usually sits below line coverage: mutants on uncovered lines are never killed, and mutants on covered lines survive when no assertion checks the result. Common surviving mutations:

  • > changed to >= → add boundary test case
  • return value removed → assert the result, not just that the method ran
  • void method call removed → assert the observable effect (DB state, event published)
  • Conditional && changed to || → test both branches

Run PIT nightly on critical paths (payments, auth); treat an 80% mutation score as a reasonable working target on core business logic, not a proven optimum.

Testcontainers + Spring Boot: shared instances and reusable mode

The naive Testcontainers pattern starts a fresh container per test class. For a suite of forty integration test classes that is forty PostgreSQL boots, each paying full container startup. The fix is a static singleton plus reusable mode, which keeps the same container alive across the entire JVM and across local test reruns.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
 
@SpringBootTest
@Testcontainers
public abstract class IntegrationTestBase {
 
    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true)
            .withDatabaseName("appdb")
            .withUsername("app")
            .withPassword("app");
 
    @ServiceConnection
    static final KafkaContainer KAFKA =
        new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"))
            .withReuse(true);
 
    static {
        POSTGRES.start();
        KAFKA.start();
    }
}

Two rules make this work. First, withReuse(true) only takes effect where testcontainers.reuse.enable=true is set in ~/.testcontainers.properties; the Testcontainers docs call reuse experimental and unsuited to CI, so enable it on developer laptops only. Second, keep the containers in static fields started from a static initializer, not managed by @Container: that makes them JVM-wide singletons shared by every subclass, and it is how the reuse docs say to start a reusable container.

Spring Boot 3.1 introduced @ServiceConnection, which replaces the older @DynamicPropertySource registry boilerplate. On a static container field it registers ConnectionDetails beans that take precedence over the spring.datasource.*, spring.kafka.*, or spring.data.redis.* connection properties. Spring Boot only scans fields (and @Bean methods) for it — on a plain static method it is silently ignored.

ArchUnit: layered architecture rules as tests

A Spring Boot service rots layer-first. Someone imports a @Repository from a controller to "just save time," and within six months the service layer is bypassed in eleven places. ArchUnit codifies the layering contract as a JUnit test that fails the build when boundaries break.

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.Architectures;
 
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
 
@AnalyzeClasses(packages = "com.example.orders")
class ArchitectureTest {
 
    @ArchTest
    static final ArchRule layerBoundaries = Architectures.layeredArchitecture()
        .consideringAllDependencies()
        .layer("Controller").definedBy("..web..")
        .layer("Service").definedBy("..service..")
        .layer("Repository").definedBy("..persistence..")
        .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
        .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
        .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
 
    @ArchTest
    static final ArchRule noJpaInControllers = noClasses()
        .that().resideInAPackage("..web..")
        .should().dependOnClassesThat()
        .resideInAnyPackage("javax.persistence..", "jakarta.persistence..");
 
    @ArchTest
    static final ArchRule serviceMethodsAreTransactional = methods()
        .that().areDeclaredInClassesThat().resideInAPackage("..service..")
        .and().arePublic()
        .should().beAnnotatedWith("org.springframework.transaction.annotation.Transactional")
        .orShould().beAnnotatedWith("jakarta.transaction.Transactional");
}

ArchUnit fails fast and points to the exact violating class — far better than discovering the breach during a postmortem six months later. Pair it with rules forbidding @Autowired field injection (constructor injection only), banning direct RestTemplate use in favour of an injected WebClient, and rejecting any @SpringBootTest outside the it. test source set.

AssertJ recursive comparison and flaky-test detection

Mockito-style argument matchers grow brittle once you assert on aggregate roots with twenty fields. AssertJ's recursive comparison flips the model: assert that the entire object graph matches, ignoring fields that change every run (timestamps, generated IDs).

import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;
 
class OrderAssemblyTest {
 
    @Test
    void assemblesOrderFromCart() {
        var actual = service.assembleOrder(cartFixture());
        var expected = expectedOrderFixture();
 
        assertThat(actual)
            .usingRecursiveComparison()
            .ignoringFields("id", "createdAt", "lines.lineId")
            .ignoringFieldsOfTypes(java.time.Instant.class)
            .isEqualTo(expected);
    }
 
    @RepeatedTest(value = 50, name = "scheduler run #{currentRepetition}/{totalRepetitions}")
    void schedulerHandlesConcurrentTriggers(RepetitionInfo info) {
        var result = scheduler.runOnce();
        assertThat(result.status()).isEqualTo(Status.OK);
        assertThat(result.duplicates()).isZero();
    }
}

@RepeatedTest(50) is the cheapest flaky-test detector available. Wrap any test that touches threads, timing, message brokers, or shared file system state and run it locally before merging. A test that passes 50 of 50 times puts the 95% upper bound on its flake rate near 6% (the rule of three: ~3/N for zero failures in N runs) — a test that passes once and is shipped tells you nothing about its flake rate. In CI, schedule a nightly job that runs the suspect tests with -Djunit.jupiter.execution.parallel.enabled=true and @RepeatedTest(200); any test that flakes once is quarantined and tagged with @Tag("flaky") until the underlying race is fixed. ⁠[2]

The matching Maven Surefire config that wires the parallel + repeated-test workflow into a Maven build — paste it once and every developer's local mvn test matches CI exactly:

<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <version>3.2.5</version>
  <configuration>
    <properties>
      <configurationParameters>
        junit.jupiter.execution.parallel.enabled=true
        junit.jupiter.execution.parallel.mode.default=concurrent
        junit.jupiter.execution.parallel.config.strategy=dynamic
        junit.jupiter.execution.parallel.config.dynamic.factor=1
      </configurationParameters>
    </properties>
    <!-- set <test.excludedGroups>flaky,integration</test.excludedGroups> in the project's <properties> -->
    <excludedGroups>${test.excludedGroups}</excludedGroups>
  </configuration>
</plugin>

The excludedGroups line is the recovery-from-flakiness lever: tests tagged @Tag("flaky") stop blocking the build while you fix them, and the nightly job runs only those tests with -Dgroups=flaky -Dtest.excludedGroups= to verify the fix landed before un-quarantining. Route the value through a property: a literal in the plugin's <configuration> wins over -DexcludedGroups, so the override would silently do nothing. Pair the config with a build-system rule that fails any PR that adds a @Tag("flaky") annotation without an accompanying ticket reference in the test name — quarantines should be temporary, never permanent, otherwise the parallel test run drifts back into hidden flakiness as the quarantine list grows.

Frequently Asked Questions

When should I use a real database vs. a mock?

Use Testcontainers (real database) for:

  • Native SQL queries, migrations, unique constraints
  • Anything that fails on H2 but works in production

Use mocks for:

  • Integration tests you want to run in <100ms
  • Testing a service's logic, not the database itself

How do I know if I'm mocking too much?

If your test has more mock setup than actual assertions, you're testing implementation details. Simplify by testing behavior: what observable effect does this code have? DB state? Event published? Response returned?

Should I run Testcontainers in CI?

Yes, on any CI runner with Docker. Testcontainers add container startup time per test class and in exchange exercise the database and broker behavior that mocked dependencies cannot. Run unit tests first; run integration tests in parallel on a separate worker.

How often should I run mutation tests?

Nightly on main branch, targeting core business logic (pricing, payments, auth). Full mutation test run is slow—don't run on every commit. Treat an 80% mutation score as a reasonable working target on critical paths, not a guarantee.

My test is flaky. Should I delete it?

Never. Flakiness is a symptom of a real bug: shared state, time dependence, or concurrency. Quarantine the test with @Tag("flaky"), investigate, fix the root cause.

Keep Reading

Sources

  1. 1.HTTP Semantics — IETF, 2022
  2. 2.JUnit 5 User Guide — junit.org, 2026
  3. 3.Mockito — main reference documentation — Mockito Project, 2026
BackendBytes Engineering Team
BackendBytes

Engineering Team

An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.

Read Next