4.2 Integration, Contracts, End-to-End, and Continuous Feedback
Unit tests can verify that core logic works under controlled conditions, but they cannot confirm that SQL is compatible with the target database, that JSON contracts haven't drifted, or that a browser can actually complete login and registration flows. The value of cross-boundary testing lies in covering these interaction points that no standalone component or internal test can determine on its own.
Integration Testing: Using Representative Real Dependencies
Repository tests should verify actual database behavior, such as:
- Whether dialects, constraints, and index definitions can be executed;
- Whether mappings for time, precision, enums, and JSON are accurate;
- Whether transaction commits, rollbacks, and isolation levels align with design;
- Whether unique constraints genuinely enforce their behavior under concurrent requests.
If production uses PostgreSQL, in-memory databases can only cover a subset of the behavior and cannot replace actual PostgreSQL integration tests. Testcontainers can launch short-lived containers for testing:
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
class JdbcRegistrationRepositoryTest {
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void storesAndLoadsRegistration() {
DataSource dataSource = DataSources.postgres(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword());
Migrations.apply(dataSource);
JdbcRegistrationRepository repository =
new JdbcRegistrationRepository(dataSource);
Registration saved = Registration.confirmed("t-1", "player-A");
repository.save(saved);
assertEquals(saved, repository.findById(saved.id()).orElseThrow());
}
}The example omits project-specific data sources and migration implementations. Professional projects should also validate migration scripts and ensure each test operates with isolated data, options include transactional rollbacks, independent schemas, cleanup scripts, or isolated containers. The choice depends on parallelization needs and test speed.
Contract Testing: Validating Shared Commitments at Service Boundaries
A client test for Service A that uses a stub to return a specific JSON payload can only prove that Service A can process that payload, it cannot verify that Service B actually provides it. Consumer-driven contract testing captures the concrete interactions a consumer depends on, then replays those interactions during provider builds to validate compliance.
Consumer test
-> Generates a "I depend on these requests/responses" contract
-> Provider build replays and validates the contract
-> Publishes validation results and determines whether the version can be safely deployedContract testing is well-suited for validating request methods, paths, fields, status codes, and compatibility rules. However, it does not replace:
- Internal business rule testing within the provider;
- Limited integration or end-to-end tests when multiple services are running together;
- Validation of latency, capacity, security, and fault tolerance.
For events, additional validation is required (beyond JSON deserialization) such as event name, schema, required fields, compatibility policies, and semantics around duplication or out-of-order delivery.
End-to-end Testing Focuses on Key User Journeys
End-to-end tests run through real entry points and major dependencies, making them closest to actual user workflows and most susceptible to environmental, data, and asynchronous timing issues. Scenarios that are worth preserving typically include:
- A user logs in and completes a core transaction;
- Critical permission boundaries are properly enforced;
- Primary components after deployment can communicate successfully;
- A few browser- or mobile-specific behaviors that cannot be reliably verified at lower levels.
Avoid enumerating every possible boundary in end-to-end tests. Algorithmic boundaries belong in unit tests, SQL constraints in integration tests, and end-to-end tests should only verify the critical paths that result when these components work together.
When tests fail, capture screenshots, console logs, network traces, service logs, and associated IDs. End-to-end tests that simply time out at step 17 offer little value.
Property Testing: From Examples to General Laws
Example tests check a few known cases, while property-based tests generate large volumes of input to verify general laws. For the rational number ADT, we can validate:
x + 0 = x
x + y = y + x
(x + y) + z = x + (y + z)
of(n, d) is equivalent to of(k*n, k*d) (where k ≠ 0)These properties must genuinely belong to the domain contract. Floating-point arithmetic generally does not satisfy exact associativity; applying mathematical laws of real numbers directly to IEEE 750 floating-point numbers leads to erroneous test results.
When property-based tests fail, they should shrink the failing input into a smaller, more manageable counterexample to aid in diagnosis. It complements manually selected boundary cases, but does not replace thoughtful specification design.
TDD is a short feedback loop, not a test categorization
The core rhythm of Red–Green–Refactor is:
- Write a test that fails due to missing behavior;
- Make the minimal change to make it pass;
- Refactor the code while keeping the test green;
- Repeat in small steps.
First, verify that the test fails for the expected reason, otherwise, a test that never fails may not have actually validated the new behavior. TDD is highly effective for exploring APIs and refining rules, but database migrations, concurrency issues, and visual experiences still require dedicated design and validation at their respective levels.
Coverage, Mutation Testing, and Test Effectiveness
Coverage answers the question "Which parts of the code have been executed," not "Are the results correctly asserted." It's useful for identifying unreachable code regions, but it should not be the sole metric for measuring test quality.
Mutation testing makes small modifications to the code (such as changing > to >=) and then checks whether the tests fail:
- If the test fails, the mutation is "killed";
- If the test still passes, it may indicate missing assertions, insufficient test cases, or the mutation being equivalent to the original code.
Mutation score must also be interpreted carefully; chasing 100% mechanically is not sufficient. For high-risk, purely logical modules, mutation testing reveals issues that pure line coverage might miss, such as tests that run but fail to verify actual outcomes.
Layering CI by Feedback Speed
A common pipeline structure is:
Pre-commit / PR fast gate
Compile + static analysis + unit tests + fast contract tests
PR or merge gate
Database integration tests + provider contract validation + limited key E2E tests
Scheduled / pre-release
Full E2E + performance + security + recovery drillsThe specific layering should be tailored to project duration and risk profile. Never silently re-run unstable tests to achieve a green status: re-runs can gather diagnostic evidence, but flaky tests must have owners, defined fix deadlines, and be isolated to prevent risk from being masked.
Test Strategy Checklist
- Does each critical risk have a corresponding validation layer?
- Are migration and persistence operations validated against a real target database?
- Are service boundaries enforced with version compatibility or contract validation?
- Do end-to-end tests cover only the most indispensable user journeys?
- Are time, random numbers, and concurrency controllable and observable?
- Does failure provide sufficient diagnostic information?
- Does CI return the cheapest, most certain feedback first?
- Do coverage and mutation results focus on risk areas rather than just a single metric?