What You’ll Learn
- Why integration-first testing aligns with functional composition
- The evolutionary process: stub everything -> implement incrementally -> production-ready
- How to handle complex test inputs with builders and factories
- When you still need unit tests
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:
- Doesn’t test composition - Steps work individually but fail when chained
- Doesn’t test error propagation - How do failures bubble through the chain?
- Doesn’t test actual behavior - Tests verify components, not use cases
- Brittle - Interface changes break all tests, even when behavior unchanged
- 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:
- Does the request get validated correctly?
- Do all steps execute in order?
- Does each step failure propagate correctly?
- Do branch conditions work as expected?
- Does the complete behavior match requirements?
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:
- Mental Overhead: One test suite per use case, not per component (+2). Test names directly map to scenarios.
- Business/Technical Ratio: Tests read like behavior specifications, not technical assertions (+3).
- Reliability: Tests verify actual end-to-end behavior, not isolated fragments (+3).
- Complexity: Fewer test contexts, clearer boundaries (business vs adapters) (+2).
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.
2. Business Leaves: Unit Tests if Complex
Simple business leaves (single calculation, simple transformation) don’t need isolated tests - they’re covered by use case integration tests.
Complex business leaves (rich algorithms, many branches) deserve unit tests:
class PricingEngineTest { @Test void volumeDiscount_appliesAtThreshold() { /* ... */ } @Test void combinedDiscounts_stackCorrectly() { /* ... */ } @Test void edgeCases_handleGracefully() { /* ... */ } }
Guideline: If a leaf has 3+ conditional branches or complex logic, write unit tests.
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.
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:
- Add step interface
- Update factory to accept dependency
- Update existing test stubs
- Add step failure scenarios
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 Approach to Use?
- Canonical Vectors: Simple use cases, few fields, limited variations
- Factory Methods: Medium complexity, systematic field variations
- Builders: Complex objects, many optional fields, many combinations
Key Takeaways
- Test composition, not components - Use case is what matters
- Stub only adapters - Database, HTTP, external services
- Evolve tests with implementation - Always green, never break-and-fix
- Three layers - Value objects (unit), complex leaves (unit), use cases (integration)
- Use test data utilities - Builders, vectors, factories reduce boilerplate
Exercises
See Appendix B for exercises on:
- Exercise 4.1: Test Structure
- Exercise 4.2: Stub Implementation
What’s Next
Testing in Practice covers testing in practice - organizing large test suites, the complete RegisterUser example, and migrating from traditional unit testing.