Skip to content

VOL 06 / CH 04 / LESSON 01

4.1 Risk-Driven Test Combinations and Unit Testing ​

A testing strategy starts with the ways a system can harm users or the team, then chooses the test layer that can detect each failure at an acceptable cost. Counting test methods comes later.

Start with a Risk List ​

List failure modes before selecting test strategies:

RiskPotential ConsequenceBetter Validation Approach
Incorrect point calculation logicUnfair rankingsUnit tests, parameterized tests, property-based tests
ORM mapping errorsData read/write failuresDatabase integration tests
Fields relied upon by consumers are deletedService-to-service call failuresContract tests
User cannot complete registration after logging inCore workflow disruptionLimited end-to-end tests
Timeout under peak trafficWidespread request failuresLoad and capacity testing
Unauthorized access to other users' recordsPrivacy breachesAuthorization rule testing, security testing

Coverage cannot replace this risk matrix. Even if every line of code is executed, there may still be no assertions verifying amount conservation, permission isolation, or idempotency of retries.

The Test Pyramid Is a Feedback Cost Model ​

"Many fast tests, fewer inter-process tests, and few end-to-end tests" is a useful direction, but there's no universal 70/20/10 ratio that applies to all systems. The mix of tests depends on architecture, risk, and tooling costs.

A pure algorithm library might rely almost entirely on in-process tests; a database extension may require extensive real database testing; a thin UI with critical risks might be most vulnerable at browser interaction points. A more prudent approach is:

  • Validate as close as possible to the source of potential failure;
  • Still cross real boundaries to verify how your code integrates with external systems;
  • Retain only high-cost tests that provide unique confidence;
  • Group tests by feedback speed, rather than blocking every edit with full test runs.

A Unit Is a Behavioral Boundary ​

A unit test verifies a small, focused behavior, typically running within a single process. This "unit" can be a function, a class, or a group of tightly coupled objects; it does not need to be mechanically equivalent to a single method.

Use Java 21 and the Maven/JUnit project from 1.1. Save Match.java under src/main/java and MatchTest.java under src/test/java. This object is confined to one thread; its checks do not make concurrent calls atomic.

The following domain object expresses match outcomes through state transitions:

java
public final class Match {
    public enum Status { READY, IN_PROGRESS, COMPLETED }

    private final String playerA;
    private final String playerB;
    private Status status = Status.READY;
    private String winner;

    public Match(String playerA, String playerB) {
        this.playerA = java.util.Objects.requireNonNull(playerA, "playerA");
        this.playerB = java.util.Objects.requireNonNull(playerB, "playerB");
        if (this.playerA.equals(this.playerB)) {
            throw new IllegalArgumentException("players must differ");
        }
    }

    public void start() {
        if (status != Status.READY) {
            throw new IllegalStateException("match is not ready");
        }
        status = Status.IN_PROGRESS;
    }

    public void recordWinner(String player) {
        if (status != Status.IN_PROGRESS) {
            throw new IllegalStateException("match is not in progress");
        }
        java.util.Objects.requireNonNull(player, "player");
        if (!player.equals(playerA) && !player.equals(playerB)) {
            throw new IllegalArgumentException("winner is not a participant");
        }
        winner = player;
        status = Status.COMPLETED;
    }

    public Status status() { return status; }
    public java.util.Optional<String> winner() {
        return java.util.Optional.ofNullable(winner);
    }
}

Tests are organized around observable behaviors:

java
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

class MatchTest {
    @Test
    void recordsWinnerAfterMatchStarts() {
        Match match = new Match("A", "B");

        match.start();
        match.recordWinner("B");

        assertAll(
                () -> assertEquals(Match.Status.COMPLETED, match.status()),
                () -> assertEquals("B", match.winner().orElseThrow())
        );
    }

    @Test
    void rejectsWinnerBeforeStartWithoutChangingState() {
        Match match = new Match("A", "B");

        assertThrows(IllegalStateException.class,
                () -> match.recordWinner("A"));
        assertEquals(Match.Status.READY, match.status());
        assertTrue(match.winner().isEmpty());
    }

    @Test
    void rejectsInvalidWinnerWithoutChangingStartedMatch() {
        Match match = new Match("A", "B");
        match.start();
        assertThrows(IllegalArgumentException.class,
                () -> match.recordWinner("C"));
        assertThrows(NullPointerException.class,
                () -> match.recordWinner(null));
        assertEquals(Match.Status.IN_PROGRESS, match.status());
        assertTrue(match.winner().isEmpty());
    }

    @Test
    void completedMatchCannotRestartOrChangeWinner() {
        Match match = new Match("A", "B");
        match.start();
        match.recordWinner("A");
        assertThrows(IllegalStateException.class, match::start);
        assertThrows(IllegalStateException.class,
                () -> match.recordWinner("B"));
        assertEquals(Match.Status.COMPLETED, match.status());
        assertEquals("A", match.winner().orElseThrow());
    }
}

A single test may contain multiple related assertions, as long as they collectively describe the same behavior. Pursuing a "one test, one assertion" rule at the expense of state, return values, and side effects actually leads to redundant setup code and reduced readability.

Using Equivalence Classes and Boundary Values to Select Test Cases ​

Don't rely on intuition when enumerating inputs. For example, consider the range allowed by level from [18, 100]. At a minimum, account for:

  • Legal equivalence classes: 18–100;
  • Lower boundary values: 17, 18, 19;
  • Upper boundary values: 99, 100, 101;
  • Extreme values that are technically allowed but domain-inappropriate.

Save the following as Eligibility.java under src/main/java, with EligibilityTest.java under src/test/java. The inclusive bounds are part of this exercise contract. Run mvn -Dtest=MatchTest,EligibilityTest test: four match tests and eight parameterized invocations.

java
public final class Eligibility {
    private Eligibility() {}
    public static boolean acceptsLevel(int level) {
        return level >= 18 && level <= 100;
    }
}

Parameterized testing is well-suited for expressing multiple test cases that follow the same rule:

java
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class EligibilityTest {
    @ParameterizedTest
    @CsvSource({
            "-2147483648, false",
            "17, false",
            "18, true",
            "19, true",
            "99, true",
            "100, true",
            "101, false",
            "2147483647, false"
    })
    void acceptsOnlySupportedLevels(int level, boolean expected) {
        assertEquals(expected, Eligibility.acceptsLevel(level));
    }
}

Test Doubles Have Different Responsibilities ​

"Mock" should not be used generically to describe all types of substitute objects:

DoublePurpose
StubProvides pre-defined return values for testing
FakeReplaces real components with simplified but runnable implementations, such as an in-memory repository
SpyRecords method calls for later inspection and verification
MockPre-defines or validates interaction protocols

When a simple Map is sufficient to implement a stable in-memory repository, a Fake is often closer to the real semantic intent than extensive stubbing. A Fake still needs to match the contract it replaces: a Map does not reproduce SQL uniqueness or transaction isolation. Mocks can check the local protocol “publish only after save returns.” The complete test below uses simplified nested types; it does not model real registration rules. Add org.mockito:mockito-core:5.20.0 with test scope to the 1.1 POM, save RegistrationServiceTest.java under src/test/java, and run mvn -Dtest=RegistrationServiceTest test.

Mockito 5 uses an inline mock maker by default. If the test JVM disallows dynamic agent attachment, configure Mockito as a startup Java agent using its Java 21 guidance; a mock-maker initialization error is an environment failure, not a failed business assertion.

java
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;

class RegistrationServiceTest {
    record Registration(String id, String tournament, String player) {}
    record RegistrationConfirmed(String id) {}
    interface RegistrationRepository { void save(Registration registration); }
    interface EventPublisher { void publish(RegistrationConfirmed event); }

    record RegistrationService(RegistrationRepository repository, EventPublisher events) {
        Registration register(String id, String tournament, String player) {
            Registration registration = new Registration(id, tournament, player);
            repository.save(registration);
            events.publish(new RegistrationConfirmed(id));
            return registration;
        }
    }

    @Test
    void publishesEventAfterSaveReturns() {
        RegistrationRepository repository = mock(RegistrationRepository.class);
        EventPublisher events = mock(EventPublisher.class);
        RegistrationService service = new RegistrationService(repository, events);

        Registration result = service.register("r-1", "t-1", "player-A");

        assertEquals(new Registration("r-1", "t-1", "player-A"), result);
        var order = inOrder(repository, events);
        order.verify(repository).save(result);
        order.verify(events).publish(new RegistrationConfirmed(result.id()));
    }

    @Test
    void doesNotPublishWhenSaveThrows() {
        RegistrationRepository repository = mock(RegistrationRepository.class);
        EventPublisher events = mock(EventPublisher.class);
        doThrow(new IllegalStateException("write failed"))
                .when(repository).save(any(Registration.class));
        RegistrationService service = new RegistrationService(repository, events);

        assertThrows(IllegalStateException.class,
                () -> service.register("r-1", "t-1", "player-A"));
        verifyNoInteractions(events);
    }
}

inOrder verifies relative order; separate verify calls do not. Even this test proves only that save returned before publish: an ORM may flush or commit later, and the process can fail between the two calls. Durable publication requires an appropriate transaction/outbox design, discussed in 15.2.

Avoid mocking value objects, collections, or complex third-party APIs you do not own. Instead, encapsulate third-party APIs behind your own interfaces, use stubs on those interfaces, and validate the adapter behavior through integration tests.

Avoid Welding Implementation Details to the Contract ​

These Mockito method-body fragments verify that calls happened, not their order. They can still pin down a cache implementation unnecessarily:

java
verify(cache).get(key);
verify(repository).find(key);
verify(cache).put(key, value);

If the test cares about "returning registration records even when a cache miss occurs," the assertion should prioritize verifying the result. Strict call ordering can cause legitimate refactors to fail. Use interaction checks for protocols and state checks for outcomes. Verifying every call turns ordinary code cleanup into test maintenance.

The Nature of a Reliable Unit Test ​

  • Reproducible: The same code and input always produce the same result.
  • Isolated from external uncertainty: Does not depend on real networks, current time, or global random states.
  • Fast and easy to debug: Failures point directly to a small, specific behavior.
  • Observes only public behavior: Results are based solely on observable, public interfaces.
  • Clearly covers normal paths, edge cases, failure modes, and state invariants.

The next section moves beyond in-process boundaries, addressing database interactions, service contracts, end-to-end workflows, and the layered execution of tests in CI environments.

References ​

Built with VitePress | Software Systems Atlas