Testing in Java

Fast, deterministic tests are the safety net for refactors and the spec for behavior you intend to keep. The modern Java stack centers on JUnit 5 (Jupiter) for structure and assertions, Mockito for isolating collaborators, and coverage/mutation tools to find gaps lines alone miss. This chapter teaches the APIs and the design habits that make code testable in the first place.

beginner mid senior

The Java testing stack

JUnit 5 runs tests and reports results; Mockito replaces slow or unavailable dependencies; build tools (Maven Surefire, Gradle test task) execute suites in CI. Spring Boot adds @SpringBootTest for integration tests—still built on Jupiter.

ToolRole
JUnit 5 JupiterTest discovery, lifecycle, assertions, extensions
MockitoMocks/spies, verification, argument capture
AssertJ (optional)Fluent assertions—often paired with JUnit
JaCoCoLine/branch coverage reports
PITMutation testing—quality of assertions

JUnit 4 (org.junit.Test) is legacy—new projects use Jupiter (org.junit.jupiter.api). Vintage engine can run JUnit 4 tests during migration.

JUnit 5 (Jupiter)

Jupiter is the programmable test engine: annotations declare tests and lifecycle, Assertions express expectations, and the extension model hooks setup/teardown without inheritance.

Lifecycle annotations

Default lifecycle is per-method: new test class instance for each @Test (unless @TestInstance(PER_CLASS)).

AnnotationScopeRuns
@BeforeAllOnce per classBefore any test; method must be static unless PER_CLASS
@BeforeEachPer testBefore each @Test
@TestThe actual test method
@AfterEachPer testAfter each test (even on failure)
@AfterAllOnce per classAfter all tests; cleanup shared resources
Java
class OrderServiceTest {
    private OrderRepository repo;
    private OrderService service;

    @BeforeEach
    void setUp() {
        repo = mock(OrderRepository.class);
        service = new OrderService(repo);
    }

    @Test
    void placesOrder() {
        when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
        Order result = service.place(new Order("A1"));
        assertEquals("A1", result.id());
    }

    @AfterEach
    void tearDown() {
        // close temp resources if any
    }
}

Assertions

Static methods on org.junit.jupiter.api.Assertions (or AssertJ) fail the test on violation with descriptive messages. Use the three-argument overload assertEquals(expected, actual, message) for clearer failures.

Java
assertEquals(42, calculator.add(40, 2));
assertTrue(user.isActive());
assertNotNull(result);

assertThrows(IllegalArgumentException.class, () -> service.refund(-1));

assertAll("user fields",
    () -> assertEquals("ada", user.name()),
    () -> assertEquals(30, user.age()));

assertTimeout(Duration.ofMillis(200), () -> service.fastCall());

assertThrows returns the exception—inspect message or type. assertAll runs every assertion and reports all failures (useful for object graphs). assertTimeout fails if code exceeds duration—prefer it over Thread.sleep in tests of timeouts.

Parameterized tests

@ParameterizedTest runs the same logic with multiple inputs—less duplication than copy-pasted @Test methods. Pair with @ValueSource (primitives/strings), @CsvSource (tabular rows), or @MethodSource (factory method or stream).

Java
@ParameterizedTest
@ValueSource(strings = { "", "  ", "\t" })
void rejectsBlank(String input) {
    assertThrows(IllegalArgumentException.class, () -> validator.requireText(input));
}

@ParameterizedTest
@CsvSource({
    "2, 3, 5",
    "0, 0, 0",
    "-1, 1, 0"
})
void adds(int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}

@ParameterizedTest
@MethodSource("invalidEmails")
void rejectsBadEmail(String email) {
    assertFalse(validator.isValidEmail(email));
}

static Stream<String> invalidEmails() {
    return Stream.of("no-at", "@nodomain", "spaces @x.com");
}

@Nested, @DisplayName, @Tag, @Disabled

@Nested inner classes group related tests with their own @BeforeEach—mirrors specs (“when cart is empty”, “when payment fails”). @DisplayName humanizes reports. @Tag("integration") filters suites in CI (Surefire groups). @Disabled("reason") skips flaky or WIP tests—prefer fixing or quarantining over silent disable.

Java
@DisplayName("Checkout service")
class CheckoutServiceTest {

    @Nested
    @DisplayName("when inventory is zero")
    class NoInventory {
        @Test
        void rejectsOrder() { /* ... */ }
    }
}

@Tag("slow")
@Disabled("waiting for API mock")
@Test void legacyFlow() { }

Test lifecycle and the extension model

JUnit 4 used @Rule and runner inheritance. Jupiter’s extension model implements interfaces like BeforeEachCallback, AfterTestExecutionCallback, ParameterResolver—registered with @ExtendWith. Mockito’s MockitoExtension is an extension; Spring’s SpringExtension injects context.

Extensions compose—order matters when multiple touch the same lifecycle phase. Custom extensions can seed databases, capture screenshots, or reset clocks.

Java
class TimingExtension implements BeforeEachCallback, AfterEachCallback {
    private long start;

    @Override
    public void beforeEach(ExtensionContext ctx) { start = System.nanoTime(); }

    @Override
    public void afterEach(ExtensionContext ctx) {
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println(ctx.getDisplayName() + " took " + ms + "ms");
    }
}

@ExtendWith(TimingExtension.class)
class TimedTests { /* ... */ }

Mockito

Mockito creates test doubles at runtime (bytecode) so unit tests target one class without databases or HTTP. Combine with constructor injection in production code—see Design Patterns: Strategy & DI.

Mock vs stub vs spy

DoubleBehaviorMockito
MockAll methods fake until stubbed; records interactionsmock(Class)
StubCanned responses—often same object as mock with whenwhen(...).thenReturn(...)
SpyWraps real object; only stubbed methods are fakedspy(real)—partial mock

Prefer mock + constructor injection over spies—spies call real methods by default and can hide integration bugs or make tests order-dependent.

@Mock, @InjectMocks, MockitoExtension

@ExtendWith(MockitoExtension.class) initializes @Mock fields and injects them into @InjectMocks (constructor first, then setters, then field injection). Manual style: OrderService service = new OrderService(mockRepo);—often clearer.

Java
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
    @Mock PaymentGateway gateway;
    @InjectMocks PaymentService service;

    @Test
    void chargesOnSuccess() {
        when(gateway.charge(any())).thenReturn(Result.ok("ch_1"));
        service.pay(Money.of(10));
        verify(gateway, times(1)).charge(argThat(m -> m.amount() == 10));
        verify(gateway, never()).refund(any());
    }

    @Test
    void propagatesGatewayFailure() {
        when(gateway.charge(any())).thenThrow(new RuntimeException("down"));
        assertThrows(RuntimeException.class, () -> service.pay(Money.of(10)));
    }
}

when().thenReturn() stubs return values; thenThrow() stubs exceptions; thenAnswer() for dynamic logic. Use lenient() sparingly for unused stubs in shared setup.

verify, ArgumentCaptor, matchers

verify(mock, times(n)) asserts interaction count; never(), atLeastOnce(), timeout() for async. ArgumentCaptor grabs values passed to mocks when equality is awkward. Matchers: any(), eq(42) (use eq when mixing matchers and raw values), argThat(predicate).

Java
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
service.placeRequest("SKU-1");
verify(repo).save(captor.capture());
assertEquals("SKU-1", captor.getValue().sku());

verify(notifier).send(eq("topic"), argThat(msg -> msg.contains("placed")));
⚠️ Pitfall

If you use any matcher in a call, all arguments must be matchers—when(repo.findById(eq(1))) not when(repo.findById(1)) mixed incorrectly. Prefer verify(mock).method(1) with raw values when possible.

Mocking static methods (Mockito 3.4+)

mockStatic(MyUtil.class) returns MockedStatic in try-with-resources—static mocks are scoped and must be closed. Use sparingly: static calls hide dependencies; refactor to instance collaborators when tests force static mocking repeatedly.

Java
try (MockedStatic<Ids> ids = mockStatic(Ids.class)) {
    ids.when(Ids::next).thenReturn("fixed-id");
    assertEquals("fixed-id", service.create().id());
}

Requires mockito-inline dependency for static and final mocking on the classpath.

Related: @MockBean in Spring Boot replaces a context bean with a mock—integration-test convenience, not a substitute for fast unit tests without Spring.

Testing principles

Tools do not replace judgment—structure tests for readability, isolate layers, and design production code so collaborators can be replaced without bytecode tricks.

AAA — Arrange, Act, Assert

Arrange builds inputs and mocks; Act calls the unit under test once; Assert checks outcomes and interactions. Blank lines between phases help reviewers. Multiple acts often mean the test should split or the unit is doing too much.

Java
@Test
void appliesDiscount() {
    // Arrange
    var cart = new Cart(List.of(line("book", 20)));
    var policy = mock(DiscountPolicy.class);
    when(policy.rate(cart)).thenReturn(0.10);
    var checkout = new Checkout(policy);

    // Act
    Money total = checkout.total(cart);

    // Assert
    assertEquals(Money.of(18), total);
}

Test pyramid

Unit tests prove class logic in milliseconds. Integration tests verify wiring (Spring context, SQL, message formats). End-to-end tests cover critical user journeys—keep the top narrow to avoid flaky CI and slow feedback.

Test doubles taxonomy

DoublePurpose
DummyFills parameter lists—never used
StubCanned answers
SpyRecords calls on real or partial real object
MockExpects specific interactions—verification
FakeWorking simplification (in-memory repo)

Fakes are valuable when behavior matters (in-memory H2 with Flyway)—mocks when you only need isolation.

Writing testable code

  • Dependency injection — pass interfaces via constructor; avoid new inside business methods
  • Avoid static singletons — clocks (Clock.systemUTC() injectable), IDs, config
  • Single responsibility — smaller units mean smaller tests
  • Pure functions — easiest to test; push I/O to edges
  • Seams — package-private for same-module tests only when necessary; don’t expose for test hacks
💡 Pro Tip

Test behavior, not implementation—over-using verify on every private collaborator couples tests to refactoring. Assert outputs and observable side effects first.

Code coverage & quality

Coverage shows what ran—not whether assertions are meaningful. Mutation testing closes that gap by breaking code and expecting tests to fail.

JaCoCo basics

JaCoCo instruments bytecode during tests and reports line, branch, and method coverage. Maven: jacoco-maven-plugin with prepare-agent and report goals. Gradle: jacoco plugin + test { finalizedBy jacocoTestReport }.

Metrics:

  • Line coverage — was the line executed?
  • Branch coverage — were both if/else paths taken?
  • Instruction coverage — bytecode-level (finer than lines)

Gate merges on sensible thresholds (e.g. 80% on new code) but never chase 100% on DTOs, generated code, or trivial getters—focus on domain packages.

XML
<!-- Maven snippet -->
<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <executions>
    <execution>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>test</phase>
      <goals><goal>report</goal></goals>
    </execution>
  </executions>
</plugin>

Mutation testing (PIT)

PIT (PITest) mutates compiled classes—flip conditionals, change constants, remove calls—and runs tests. If tests still pass, the mutant survived (weak test). If tests fail, the mutant was killed (good). Slower than coverage alone; run nightly or on critical modules.

High line coverage with low mutation score means assertions do not detect regressions—e.g. missing assertEquals on return value.

🎯 Interview Tip

Explain JUnit 5 lifecycle vs JUnit 4, when to use @ParameterizedTest, difference between mock and spy, and what JaCoCo measures vs what PIT measures. Mention test pyramid and why integration tests use Testcontainers instead of mocking the database.

📦 Real World

CI pipelines: unit tests on every push (< 2 min), integration on main, mutation weekly. Tag slow tests with @Tag("slow") and exclude from PR jobs. Flaky tests are bugs—fix or delete, do not @Disabled indefinitely.