Chapter 11: Testing Philosophy
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: Chapter 10: 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: Building test data builders
- Exercise 4.2: Evolutionary testing walkthrough
What’s Next
Chapter 12 covers testing in practice - organizing large test suites, the complete RegisterUser example, and migrating from traditional unit testing.