Skip to content

VOL 06 / CH 04 / LESSON 02

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. The following Java 21/JUnit 5 example uses Testcontainers 1.21.4 and PostgreSQL 16. It requires an accessible Docker-compatible runtime. Add these test dependencies inside the dependencies element of the 1.1 POM; these pinned exercise versions use the 1.x package names. Testcontainers 2.x changes module names and database-class packages, so do not mix its snippets with these dependencies. The JDBC driver is a separate dependency.

xml
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>1.21.4</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <version>1.21.4</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.7.8</version>
  <scope>test</scope>
</dependency>

Save the complete example as src/test/java/JdbcRegistrationTest.java and run mvn -Dtest=JdbcRegistrationTest test. The temporary table belongs to this connection and disappears when it closes.

java
import static org.junit.jupiter.api.Assertions.*;
import java.sql.DriverManager;
import java.sql.SQLException;
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 JdbcRegistrationTest {
    @Container
    static final PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    @Test
    void uniqueRegistrationRejectsDuplicatesAndRollbackRemovesUncommittedRows()
            throws SQLException {
        try (var connection = DriverManager.getConnection(postgres.getJdbcUrl(),
                     postgres.getUsername(), postgres.getPassword());
             var sql = connection.createStatement()) {
            sql.execute("""
                    CREATE TEMP TABLE registration (
                        id text PRIMARY KEY,
                        tournament text NOT NULL,
                        player text NOT NULL,
                        UNIQUE (tournament, player)
                    )
                    """);
            sql.executeUpdate("INSERT INTO registration VALUES ('r-1', 't-1', 'A')");
            SQLException duplicate = assertThrows(SQLException.class, () ->
                    sql.executeUpdate("INSERT INTO registration VALUES ('r-2', 't-1', 'A')"));
            assertEquals("23505", duplicate.getSQLState());

            connection.setAutoCommit(false);
            sql.executeUpdate("INSERT INTO registration VALUES ('r-3', 't-1', 'B')");
            connection.rollback();
            try (var rows = sql.executeQuery("SELECT id, player FROM registration")) {
                assertTrue(rows.next());
                assertEquals("r-1", rows.getString("id"));
                assertEquals("A", rows.getString("player"));
                assertFalse(rows.next());
            }
        }
    }
}

The first row commits under JDBC auto-commit. A duplicate tournament/player pair must produce SQLSTATE 23505; the later explicit transaction rolls back the second player, leaving only r-1. This validates the stated SQL behavior, not a repository mapper, production migrations, or concurrent isolation. Add those tests around the actual adapter and migration files. The static container is shared within this test class; it does not reset data between methods. Keep this example sequential and isolate additional tests with schemas, cleanup, or separate containers.

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 show that Service A handles 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.

text
Consumer test
  -> Generates an "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 deployed

Contract 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 example, a consumer expects registrationId to be a non-null string. A provider that renames it to id must fail that interaction contract, even if both sides accept valid JSON. Validate the exact consumer/provider versions intended for deployment; “some provider build passed” is insufficient. For events, check message names, schemas, required fields, and compatibility. Message contract tests usually bypass the broker, so duplicate and out-of-order handling need separate consumer tests, while delivery and acknowledgement behavior need broker integration tests.

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:

text
x + 0 = x
x + y = y + x
(x + y) + z = x + (y + z)
of(n, d) is equivalent to of(k*n, k*d) (d != 0, k != 0)

For the BigInteger-based Rational in 2.1, generate bounded nonzero denominators and nonzero scaling factors, and multiply as BigInteger before calling of. Multiplying long values first can overflow before the ADT sees the input. Retain the failing seed and shrunk case for reproduction. Generated examples provide evidence over sampled inputs, not a proof for every rational number.

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 754 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. This 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:

  1. Write a test that fails due to missing behavior;
  2. Make the minimal change to make it pass;
  3. Refactor the code while keeping the test green;
  4. Repeat in small steps.

First verify that the test fails for the expected reason. A test that never fails may not exercise 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 code not exercised by the current tests, 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:

text
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 drills

The specific layering should be tailored to test runtime and risk. 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?

References ​

Built with VitePress | Software Systems Atlas