The Java Realization / JBCT course / Part IV — Testing

Testing Philosophy

Lesson 1 of 2 · Part IV — Testing

In this lesson

The pattern here (stub, then implement one step at a time) is what the worked examples later in the course follow.

What You’ll Learn

Prerequisites: Thread Safety


The Problem with Traditional Testing

Traditional Approach: Component-Focused

Most Java testing follows this pattern:

// Separate tests for each component
class ValidateInputTest {
    @Test void emailValidation() { /* ... */ }
    @Test void passwordValidation() { /* ... */ }
    // 10 tests
}

class CheckCredentialsTest {
    @Test void validCredentials() { /* ... */ }
    @Test void invalidCredentials() { /* ... */ }
    // 5 tests
}

class CheckAccountStatusTest {
    @Test void activeAccount() { /* ... */ }
    @Test void inactiveAccount() { /* ... */ }
    // 3 tests
}

class GenerateTokenTest {
    @Test void tokenGeneration() { /* ... */ }
    // 4 tests
}

// Total: 22 tests, never testing them TOGETHER

Problems:

  1. Doesn’t test composition - Steps work individually but fail when chained
  2. Doesn’t test error propagation - How do failures bubble through the chain?
  3. Doesn’t test actual behavior - Tests verify components, not use cases
  4. Brittle - Interface changes break all tests, even when behavior unchanged
  5. False confidence - All tests pass, production fails because integration untested

What We Actually Want to Test

When a user calls UserLogin.execute(request), we care about:

These are integration questions, not unit questions.


Philosophy: Integration-First Testing

The Core Principle

Test assembled use cases, not isolated components.

Your use case is a composition of steps. Test the composition. Stub only at adapter boundaries (database, HTTP, external services). Test all business logic together.

Why by criteria:

The Three Testing Layers

1. Value Objects: Unit Tests (100% coverage)

Value objects are pure, isolated, and enforce invariants. Test them comprehensively:

class EmailTest {
    @ParameterizedTest
    @ValueSource(strings = {"bad", "no@domain", "@missing", "[email protected]"})
    void email_rejectsInvalidFormat(String raw) {
        Email.email(raw).onSuccess(Assertions::fail);
    }

    @Test
    void email_normalizesToLowercase() {
        Email.email("[email protected]")
             .onSuccess(email -> assertEquals("[email protected]", email.value()));
    }
}

Why unit test here? Value objects have zero dependencies. They’re pure functions. Unit testing is natural.

But “100% coverage” is the wrong target, and the example above shows why. Four hand-picked strings reach 100% line coverage of Email. So would two. The metric reports the same number for a careful suite and a lucky one, because it measures the paths the code has rather than the space it decides over.

Count the space instead, and let it choose the shape of the tests:

The space Write Because
Small and enumerable (a status enum, a three-way branch) examples the space is the examples
A finite grid (an enum against an enum, banded ranges) a table – @ParameterizedTest with one row per cell the cell count is known, so a missing row is a visible hole
Unbounded (any string, any BigDecimal, any timestamp) state the invariant examples sample an infinite space arbitrarily, and four are as arbitrary as one

Email is the third kind. Four strings do not cover the space of malformed addresses; they cover four of them. The honest form of the test is the property the parser guarantees – that normalization is idempotent, that no accepted value fails the invariant – which is what the tools called property-based testing libraries exist to check by generating inputs rather than listing them. This book does not require one. The obligation for an unbounded space has three parts: the test’s assertion is the invariant, applied to every supplied input rather than a hand-computed expected value per example; the input set includes the boundaries; and a failure reproduces, so generated inputs run under a fixed or reported seed. How the inputs are supplied – a property library, a hand-rolled generator, an enumerated set beyond the boundaries – is style. Writing the invariant down is still what stops four examples from being mistaken for coverage of the space; written as an assertion it also fails when violated, which a comment never does. Appendix A shows the invariant realized with a property library.

Quantity (1…100) is the first kind wearing the clothes of the third. It has 102 interesting values including the boundaries, and boundary examples genuinely cover it.

The worked case is PriceCalculator in PlaceOrder – eighteen nominal combinations, three of them structurally impossible, fifteen rows in a table.

2. Business Leaves: The Same Table Chooses

The space-counting table above is not only for value objects; it assigns the testing obligation for every business leaf. Count the space the leaf decides over:

class PricingEngineTest {
    @Test void volumeDiscount_appliesAtThreshold() { /* ... */ }
    @Test void combinedDiscounts_stackCorrectly() { /* ... */ }
}

The obligation is derivable before the body exists: the space is determined by the step’s input types and the decision it makes, both present in the design. Two implementers of one leaf derive the same tests. A branch count never decides anything here – a branch-free interest formula still computes over an unbounded domain and still carries the invariant obligation.

3. Use Cases: Integration Tests (Test Vectors)

The heart of your testing: test complete use case behavior with all steps assembled, only adapters stubbed.

class UserLoginTest {
    CheckCredentials mockCredentials;
    CheckAccountStatus mockStatus;
    GenerateToken mockToken;
    UserLogin useCase;

    @BeforeEach
    void setup() {
        mockCredentials = vr -> Result.success(new Credentials("user-1"));
        mockStatus = c -> Result.success(new Account(c.userId(), true));
        mockToken = acc -> Result.success(new Response("token-" + acc.userId()));
        useCase = UserLogin.userLogin(mockCredentials, mockStatus, mockToken);
    }

    @Test
    void execute_succeeds_forValidInput() {
        var request = new Request("[email protected]", "Valid123", null);

        useCase.execute(request)
               .onFailure(Assertions::fail)
               .onSuccess(response -> assertEquals("token-user-1", response.token()));
    }
}

This tests real behavior: validation -> credentials -> status -> token, with error propagation.

Three rules the examples in this book follow

All three are visible in the worked examples, and none is obvious enough to leave unstated.

Assert on the outcome, except when the effect is invisible in it.

A stub tells you what a step returned. It does not tell you the step was called. Most of the time that is fine – if the outcome is right, the steps ran. But some behavior leaves no trace in the response:

There, capturing the call is the only oracle that can see the behavior under test, and TransferFunds and PublishArticle both do exactly that.

Everywhere else, capturing calls couples the test to the implementation for nothing. The rule is not “avoid mocks” and it is not “verify interactions” – it is assert on the effect where the effect is visible, and on the call only where it is not. That yields far fewer interaction assertions than a mock-first habit produces, and a firmly non-zero number, which a no-mocks rule gets wrong.

One composition test for propagation, N cheap vectors for the space.

RegisterUser tests validation twice: directly against ValidRequest, and through execute. That looks like duplication and is not.

The composition adds exactly one fact – that a validation failure short-circuits the remaining steps – and one test establishes it. The rest of the input space belongs where the vectors are cheap, which is the isolated level. Splitting them that way costs one test and buys the whole space; testing the space at the composition costs a full assembly per vector, and every failure names the use case rather than the rule.

Four facts live at the composition, and nothing else does.

The rule above says the composition adds one fact about validation. Asked in general – which facts does a composition establish that no leaf test can? – the answer is four kinds, and they were the same four in every use case examined while writing this section:

Everything else belongs to the leaf, and two candidates that look like composition obligations are worth naming because they are not:

Failure propagation, step by step. It is tempting to test that a failure at step four prevents step five, and then to do it for every step. But short-circuiting is flatMap’s behavior, established once by the library, not a fact about your use case. What the composition can get wrong is the wiring – a step omitted, map where flatMap was meant – and the success path already catches that.

The content of a step’s failure. If a step can fail only by delegating to a rule, test the rule. Assert that the disbursement rules reject a bad principal; do not assert that their rejection travels up the chain.

One shape rules itself out. A step whose every return is .success(...) is not fallible at all, and its failure test cannot be written. That is a return-kind violation rather than a missing test – the signature claims a contract the body does not have, and the fix is to return the plain value and chain it with map.

Why the leaf, and not the composition: that is where the branches are.

The allocation above can be argued from the combinators – flatMap short-circuits, so the composition adds propagation and nothing else. It can also be measured, and the measurement is blunter than the argument.

Mutation testing seeds a program with small syntactic faults – negate a conditional, move a boundary by one, change an operator – and reports which ones a test suite fails to detect. The seeding is mechanical: a mutant can only be planted where the bytecode makes a decision. Run that over a JBCT codebase and the mutants map the decidable surface, whatever anyone believes about where the logic is.

Across two JBCT codebases of comparable size – different domains, different structural idioms, one of them written before this rule existed – 441 logic mutants were generated, and not one of them landed in a composition. Every branch a fault could hide in sat in a value-object predicate, a rule, a gate, or a classifier. The composition layer is not merely a poor place to spend testing effort; there is nothing there to get wrong. Its faults are wiring faults – a step omitted, a step in the wrong order – and the success path catches those.

That is what makes the four facts a boundary rather than a budget. They are the whole of what a composition can establish, because they are the whole of what a composition can do.

A caution the same measurement raises. Mutation testing answers the question this chapter otherwise leaves open – whether a test discharges an obligation or merely exercises it, since a test asserting only that a call failed will survive a mutant that changes which failure it was. It is a diagnostic and not a target. Two thirds of the raw mutants generated over the codebases above were replacements of return values with null or empty defaults, which in code that forbids null is noise rather than signal; filtering the mutation operators to the ones that model real faults is a precondition, not a refinement. And a codebase that composes its predicates from a tested library rather than writing them inline generates very few mutants indeed – not because its logic is simple, but because its decisions have moved into code the tool does not mutate. A near empty mutation report is a question, not a result.


The Evolutionary Testing Process

Overview

Instead of writing tests after implementation, evolve them alongside implementation:

Phase 1: Stub Everything
    |
Phase 2: Implement & Test Validation
    |
Phase 3-N: Implement Steps Incrementally
    |
Final: Production-Ready

At each phase, all tests remain green. You’re not breaking and fixing - you’re growing.

Phase 1: Stub Everything

Goal: Establish test structure before implementing anything.

Step 1: Create use case interface with factory returning stub implementation:

public interface UserLogin {
    record Request(String email, String password, String referral) {}
    record Response(String token) {}

    Result<Response> execute(Request request);

    static UserLogin userLogin() {
        return request -> Result.success(new Response("stub-token"));
    }
}

Step 2: Write initial tests:

class UserLoginTest {
    @Test
    void execute_succeeds_forValidInput() {
        var useCase = UserLogin.userLogin();
        var request = new Request("[email protected]", "Valid123", null);

        useCase.execute(request)
               .onSuccess(response -> assertEquals("stub-token", response.token()));
    }
}

Phase 2: Implement Validation

Step 1: Add validated request with validation logic:

record ValidRequest(Email email, Password password, Option<ReferralCode> referral) {
    static Result<ValidRequest> validRequest(Request raw) {
        return Result.all(Email.email(raw.email()),
                          Password.password(raw.password()),
                          ReferralCode.referralCode(raw.referral()))
                     .map(ValidRequest::new);
    }
}

Step 2: Update factory to use validation:

static UserLogin userLogin() {
    return request -> ValidRequest.validRequest(request)
                                  .map(_ -> new Response("stub-token"));
}

Step 3: Add validation test vectors:

@Test
void execute_fails_forInvalidEmail() {
    var useCase = UserLogin.userLogin();
    var request = new Request("bad-email", "Valid123", null);

    useCase.execute(request)
           .onSuccess(Assertions::fail);
}

@Test
void execute_aggregatesMultipleErrors() {
    var useCase = UserLogin.userLogin();
    var request = new Request("bad", "weak", "invalid-ref");

    useCase.execute(request)
           .onSuccess(Assertions::fail)
           .onFailure(cause -> assertInstanceOf(Causes.CompositeCause.class, cause));
}

Phase 3-N: Continue Expanding

Repeat for each remaining step:


Handling Complex Input Objects

Test Data Builders

Fluent API for constructing test data:

public class TestData {
    public static RequestBuilder request() {
        return new RequestBuilder();
    }

    public static class RequestBuilder {
        private String email = "[email protected]";
        private String password = "DefaultValid123";
        private String referral = null;

        public RequestBuilder withEmail(String email) {
            this.email = email;
            return this;
        }

        public RequestBuilder withPassword(String password) {
            this.password = password;
            return this;
        }

        public Request build() {
            return new Request(email, password, referral);
        }
    }
}

Usage:

var request = TestData.request().build();
var invalidEmail = TestData.request().withEmail("bad").build();

Canonical Test Vectors

Pre-defined test data constants:

public interface TestVectors {
    Request VALID = new Request("[email protected]", "Valid123", null);
    Request INVALID_EMAIL = new Request("bad", "Valid123", null);
    Request WEAK_PASSWORD = new Request("[email protected]", "weak", null);
    Request MULTIPLE_ERRORS = new Request("bad", "weak", "invalid");
}

Which Form to Use

Any of the three discharges the obligation: which tests exist and what they assert never depends on how inputs are constructed, so the choice is style. The default in this book – canonical vectors; a factory method when one field varies systematically; a builder when the input type has optional fields. The examples use vectors throughout, so variation in test-data form never carries meaning.


Key Takeaways

  1. Test composition, not components - Use case is what matters
  2. Stub only adapters - Database, HTTP, external services
  3. Evolve tests with implementation - Always green, never break-and-fix
  4. Three layers - Value objects (unit), complex leaves (unit), use cases (integration)
  5. Use test data utilities - Builders, vectors, factories reduce boilerplate

Exercises

See Appendix B for exercises on:


What’s Next

Testing in Practice covers testing in practice - organizing large test suites, the complete RegisterUser example, and migrating from traditional unit testing.

Exercise — Test a Value Object's Boundaries ~15 min

Pick a value object factory in your codebase. Write tests for its boundary cases: minimum valid, maximum valid, and the failure just outside each boundary, using functional assertions (onSuccess/onFailure) instead of exception matchers. Solution discussion in the book’s Appendix B (Exercise 4.1).