View source on GitHub

Module 27 - Integration Testing

Integration tests verify that components work correctly together with real infrastructure - a real database, a real HTTP stack, a real message broker. This module covers two tools that make this practical in Java:

Tool Role
REST-assured Fluent HTTP client DSL for asserting REST API responses
Testcontainers Manages Docker containers from JUnit test code

Why Integration Tests?

Unit tests with mocks verify that code compiles and individual methods behave correctly in isolation. Integration tests catch a different class of bug:

  • SQL that works in H2 fails on PostgreSQL (different type system, RETURNING clause, BIGSERIAL)
  • HTTP serialisation / deserialisation edge cases (number types, null fields)
  • Repository code that queries correctly but maps columns wrong
  • Transactional behaviour under concurrent requests

The rule of thumb: mock at the boundary of your own code; use real infrastructure for anything that involves a third-party system.


Embedded HTTP Server for REST-assured Tests

The ProductApiServer uses the JDK’s built-in com.sun.net.httpserver.HttpServer (stable since Java 6, no external dependency):

HttpServer server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/api/products", this::dispatch);
server.start();
int port = server.getAddress().getPort();  // OS-assigned free port

Binding to port 0 lets the OS pick a free port, eliminating port-conflict flakiness between test runs.


REST-assured Basics

REST-assured uses a given / when / then BDD style that reads like a specification:

given()
    .contentType(ContentType.JSON)
    .body(Map.of("name", "Widget", "price", 9.99, "category", "gadgets"))
.when()
    .post("/api/products")
.then()
    .statusCode(201)
    .body("id",       greaterThan(0))
    .body("name",     equalTo("Widget"))
    .body("category", equalTo("gadgets"));

Test Lifecycle for REST-assured

static ProductApiServer server;
static InMemoryProductRepository repository;

@BeforeAll
static void startServer() {
    repository = new InMemoryProductRepository();
    server     = new ProductApiServer(new ProductService(repository));
    server.start(0);

    RestAssured.port    = server.port();  // configure base URL once
    RestAssured.baseURI = "http://localhost";
}

@AfterAll static void stopServer() { server.stop(); }

@BeforeEach void clearData()       { repository.clear(); }   // test isolation

Starting the server once in @BeforeAll avoids the overhead of restarting it for every test. @BeforeEach clears the in-memory repository so each test has a clean slate.


REST-assured - Key Patterns

Path parameters

when().get("/api/products/{id}", 42).then().statusCode(200);

Query parameters

when().get("/api/products?category=gadgets")
    .then().body("$", hasSize(2));

Status codes

.then().statusCode(201)   // Created
.then().statusCode(204)   // No Content
.then().statusCode(404)   // Not Found
.then().statusCode(400)   // Bad Request

Body assertions with GPath + Hamcrest

.body("$",    hasSize(3))              // array size
.body("$",    empty())                 // empty array
.body("name", equalTo("Widget"))       // top-level field
.body("name", hasItems("A", "B"))      // list contains values
.body("name", not(hasItem("X")))       // list does not contain
.body("id",   greaterThan(0))          // numeric comparison

Extracting values for chained requests

int id =
    given()
        .contentType(ContentType.JSON)
        .body(Map.of("name", "Gadget", "price", 24.99, "category", "tech"))
    .when()
        .post("/api/products")
    .then()
        .statusCode(201)
        .extract().path("id");   // pull typed value from response JSON

when().get("/api/products/{id}", id).then().statusCode(200);

Testcontainers - Core Concepts

Testcontainers starts a real Docker container from your JUnit test code and tears it down automatically when the test suite finishes.

Annotations

@Testcontainers(disabledWithoutDocker = true)  // skip gracefully if Docker absent
class PostgresProductRepositoryTest {

    @Container
    static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("tester")
            .withPassword("secret");
}

@Container on a static field: one container shared across all tests in the class (started once).

@Container on an instance field: a fresh container for every test method (more isolation, much slower).

Lifecycle

@BeforeAll (static @Container)
  ↓ Docker pull + container start
  ↓ All test methods (share one container)
@AfterAll
  ↓ Container stop + remove

Connecting to the container

conn = DriverManager.getConnection(
    postgres.getJdbcUrl(),     // e.g. jdbc:postgresql://localhost:54321/testdb
    postgres.getUsername(),    // "tester"
    postgres.getPassword()     // "secret"
);

The getJdbcUrl() includes the mapped port (random each run).


Integration Test Lifecycle for a Database

@BeforeEach
void setUpSchemaAndConnection() throws SQLException {
    conn = DriverManager.getConnection(...);
    try (Statement st = conn.createStatement()) {
        st.execute("DROP TABLE IF EXISTS products");   // clean slate
        st.execute(JdbcProductRepository.CREATE_TABLE);
    }
    repository = new JdbcProductRepository(conn);
}

@AfterEach
void closeConnection() throws SQLException { conn.close(); }

Recreating the schema in @BeforeEach guarantees every test works with an empty table, regardless of what other tests did.


JDBC Patterns in Tests

RETURNING clause (PostgreSQL-specific)

String sql = "INSERT INTO products (name, price, category) VALUES (?, ?, ?) RETURNING id";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, name);
    ps.setBigDecimal(2, BigDecimal.valueOf(price));
    ps.setString(3, category);
    try (ResultSet rs = ps.executeQuery()) {   // use executeQuery with RETURNING
        rs.next();
        long generatedId = rs.getLong("id");
    }
}

Testing against real PostgreSQL catches:

  • BIGSERIAL auto-increment behaviour (always positive, always unique, always increasing)
  • DECIMAL(10,2) precision rounding
  • RETURNING clause (not supported in H2 by default)

disabledWithoutDocker

@Testcontainers(disabledWithoutDocker = true)
class MyContainerTest { ... }

When Docker is not available (e.g. CI without Docker-in-Docker, or a developer machine without Docker Desktop), the entire test class is skipped rather than failing. The build stays green; the skip is visible in the test report.


Container Image Strategy

new PostgreSQLContainer<>("postgres:16-alpine")

Prefer:

  • Pinned versions (postgres:16-alpine) over latest - reproducible builds
  • Alpine variants where available - smaller image, faster pull
  • Official images - well-tested, minimal attack surface

Test Pyramid Recap

         /\
        /  \
       / E2E \      few, expensive, slow, realistic
      /--------\
     / Integration\  this module - real infra, focused scope
    /------------\
   /  Unit Tests  \  many, fast, isolated, mock dependencies
  /________________\

Integration tests sit in the middle: they use real infrastructure but test a bounded scope (one repository, one service, one API layer). Keep them focused and fast by using containers instead of shared test databases.


JdbcProductRepository - Key Design Decisions

Decision Rationale
Accepts Connection (not DataSource) Tests can control transactions; simpler for demos
Wraps SQLException in RuntimeException Keeps the ProductRepository interface clean
Uses RETURNING id instead of getGeneratedKeys() More explicit; shows PostgreSQL-specific SQL
Maps rows with a private mapRow() helper Single point of change if column names change