Skip to content

1. Tests and CI

A pipeline is only as good as the tests it runs. This step adds the first integration test of BoxOffice — the real catalog against a real PostgreSQL and a real Redis, started by Testcontainers — and the GitHub Actions workflow that runs it on every push and pull request. All commands run from the root of your boxoffice repository.

An integration test with Testcontainers

Mocks test what you think the database does. Testcontainers starts the real thing in Docker for the duration of the test: Flyway migrations, JPA mappings, SQL, the Redis cache and the JSON error format are all exercised exactly as in production. Spring Boot's @ServiceConnection points the datasource and the Redis client at the containers — no URLs to configure.

services/catalog/src/test/java/boxoffice/catalog/CatalogApiTest.java
package boxoffice.catalog;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

/**
 * Starts the real service against a real PostgreSQL 17 and a real Redis, in throwaway containers:
 * Flyway migrations, JPA mappings, the cache, JSON and the error format are tested together.
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
@Testcontainers
class CatalogApiTest {

    @Container
    @ServiceConnection // Spring Boot points the datasource at this container
    static PostgreSQLContainer db = new PostgreSQLContainer("postgres:17");

    @Container
    @ServiceConnection(name = "redis") // ... and the Redis client at this one
    static GenericContainer<?> redis = new GenericContainer<>("redis:8-alpine").withExposedPorts(6379);

    @Autowired
    TestRestTemplate http;

    @Test
    void returnsTheFlashSaleEvent() {
        ResponseEntity<EventOut> res = http.getForEntity("/catalog/events/1", EventOut.class);

        assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(res.getBody()).isNotNull();
        assertThat(res.getBody().title()).isEqualTo("BoxOffice Fest — Stadium Night");
    }

    @Test
    void unknownEventIsAProblem404() {
        ResponseEntity<String> res = http.getForEntity("/catalog/events/999999999", String.class);

        assertThat(res.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(res.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PROBLEM_JSON);
    }

    @Test
    void invalidEventIsRejected() {
        ResponseEntity<String> res = http.postForEntity("/catalog/events",
                new EventIn("", null, null, null, null), String.class);

        assertThat(res.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
    }
}

The pom.xml of catalog gains the test dependencies (and, for step 5, the Prometheus registry). The complete file:

services/catalog/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>catalog</artifactId>
    <version>1.0.0</version>
    <name>catalog</name>
    <description>BoxOffice catalog: events and venues</description>

    <properties>
        <java.version>21</java.version>
        <springdoc.version>3.1.1</springdoc.version>
        <!-- lab 11: the Trivy gate found fixable CRITICAL CVEs in the Tomcat of Spring Boot 4.1.1 (11.0.24);
             drop this line when the parent brings 11.0.25 or later -->
        <tomcat.version>11.0.27</tomcat.version>
    </properties>

    <dependencies>
        <!-- REST API (Spring MVC on Tomcat) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <!-- Bean Validation (@NotBlank, @Positive, ...) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- JPA + Hibernate -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <!-- health, metrics -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Redis client (Lettuce) + StringRedisTemplate: the cache of lab 5 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- OpenAPI document + Swagger UI -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
        <!-- lab 11: /actuator/prometheus, scraped in Kubernetes for the canary analysis -->
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>
        <!-- lab 11: tests (JUnit 5, AssertJ, TestRestTemplate) against real containers -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-restclient-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-postgresql</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
Dependency Why
spring-boot-starter-webmvc-test JUnit 5, AssertJ, Spring's test support for MVC applications
spring-boot-starter-restclient-test TestRestTemplate, a real HTTP client against the random port
spring-boot-testcontainers @ServiceConnection: Boot reads host/port/credentials from the containers
testcontainers-postgresql, testcontainers-junit-jupiter the PostgreSQL module and the @Testcontainers/@Container lifecycle (Testcontainers 2)
micrometer-registry-prometheus /actuator/prometheus, used by the canary analysis of step 5

The tomcat.version property replaces the Tomcat that Spring Boot 4.1.1 brings (11.0.24): the Trivy gate of step 2 rejects it for fixable CRITICAL vulnerabilities. Remove the property when Dependabot updates the parent to a version with a newer Tomcat.

Run it locally (needs Docker running, and JDK 21 + Maven from lab 0):

cd services/catalog && mvn -B verify...
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.575 s -- in boxoffice.catalog.CatalogApiTest
[INFO] BUILD SUCCESS
cd ../..

You should see Tests run: 3, Failures: 0 and BUILD SUCCESS; the first run also downloads the postgres:17 and redis:8-alpine images. If you do not have JDK 21 locally, skip this command: the CI runner executes exactly the same one.

Tests must not depend on order or on shared data

The test reads event 1 (created by the seed migration) and only fails to create an event, so it leaves no state behind. Tests that create data should use unique values (a UUID in the title) or run each in a transaction that rolls back.

The workflow

GitHub Actions runs workflows (.github/workflows/*.yaml) made of jobs (each on a fresh virtual machine, a runner) made of steps (shell commands or reusable actions). This lab's CI has three jobs; this step explains the first, the next steps the others.

.github/workflows/ci.yaml
# Continuous integration of BoxOffice (lab 11).
# Every push and pull request: tests + a performance budget. Pushes to main also build, scan,
# sign and publish the images. The name "CI" is referenced by deploy.yaml (and by lab 13).
name: CI

on:
  push:
    branches: [main]
    paths-ignore: ["k8s/**", "results/**", "**.md"]   # deploy commits must not trigger CI again
  pull_request:

permissions:
  contents: read            # least privilege by default; jobs ask for more when they need it

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: test (${{ matrix.service }})
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        service: [catalog, inventory, auth, gateway, order, notification, payment]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
        with:
          distribution: temurin
          java-version: "21"
          cache: maven
      - name: Build and test (Testcontainers starts PostgreSQL and Redis in Docker)
        working-directory: services/${{ matrix.service }}
        run: mvn -B verify
      - name: Keep the test reports when something fails
        if: failure()
        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: test-reports-${{ matrix.service }}
          path: services/${{ matrix.service }}/target/surefire-reports/

  performance:
    name: performance budget
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Start PostgreSQL, Redis and catalog
        run: docker compose -f .github/ci/compose.perf.yaml up -d --build --wait --wait-timeout 300
      - uses: grafana/setup-k6-action@43b9fc21641a76002687994433dd586f56e791b1 # v1.2.2
        with:
          k6-version: "1.8.2"
      - name: k6 budget (fails the job when a threshold is crossed)
        uses: grafana/run-k6-action@4082a08c7e40cf6d65863911077dfb60ae0a4f04 # v1.5.0
        env:
          BASE_URL: http://localhost:8082
        with:
          path: load/budget.js
      - name: Service logs on failure
        if: failure()
        run: docker compose -f .github/ci/compose.perf.yaml logs catalog | tail -200

  image:
    name: image (${{ matrix.service }})
    needs: [test, performance]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write          # push to ghcr.io
      id-token: write          # keyless signing of the attestations (Sigstore)
      attestations: write      # store the provenance and SBOM attestations
      security-events: write   # upload the Trivy report to the Security tab
    strategy:
      matrix:
        service: [catalog]     # the service deployed by Argo CD in this lab; add the others later
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - name: Image name (GHCR needs lowercase)
        run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/boxoffice-${{ matrix.service }}" >> "$GITHUB_ENV"
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha,format=long
            type=raw,value=main
      - name: Build (not pushed yet)
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: services/${{ matrix.service }}
          load: true
          tags: boxoffice-${{ matrix.service }}:ci
          cache-from: type=gha,scope=${{ matrix.service }}
          cache-to: type=gha,mode=max,scope=${{ matrix.service }}
      - name: Vulnerability report (SARIF, never fails)
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: boxoffice-${{ matrix.service }}:ci
          format: sarif
          output: trivy.sarif
          severity: CRITICAL,HIGH
          exit-code: "0"
      - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
        with:
          sarif_file: trivy.sarif
          category: trivy-${{ matrix.service }}
      - name: Gate - no CRITICAL vulnerability that has a fix
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: boxoffice-${{ matrix.service }}:ci
          format: table
          severity: CRITICAL
          ignore-unfixed: true
          exit-code: "1"
      - name: SBOM (SPDX)
        uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
        with:
          image: boxoffice-${{ matrix.service }}:ci
          format: spdx-json
          output-file: sbom.spdx.json
      - name: Push (same layers, now to the registry)
        id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: services/${{ matrix.service }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha,scope=${{ matrix.service }}
      - name: Attest build provenance (who built it, from which commit, how)
        uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          push-to-registry: true
      - name: Attest the SBOM
        uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.push.outputs.digest }}
          sbom-path: sbom.spdx.json
          push-to-registry: true
Part What it does
name: CI the name other workflows refer to (deploy.yaml runs after "CI"; lab 13 too). Do not rename it
on: push (main) / pull_request every change is tested before and after it is merged; changes only to k8s/**, results/** or Markdown do not rebuild
permissions: contents: read the default token can only read the repository; the image job asks for exactly what it needs
concurrency a new push to the same branch cancels the run that is now obsolete
test job, matrix.service one parallel job per Java service; fail-fast: false reports every broken service, not only the first
actions/setup-java with cache: maven downloads JDK 21 and caches ~/.m2 between runs (minutes → seconds)
mvn -B verify compiles, runs the unit and Testcontainers tests (Docker is available on GitHub's Ubuntu runners)
upload-artifact on failure the surefire reports of a failed run, downloadable from the run page

Services without tests still run mvn verify: it proves they compile and package. Add tests to them as you go — the matrix already includes them.

Push and watch

The performance job of this workflow runs two files that step 3 explains: .github/ci/compose.perf.yaml and load/budget.js. Create them now, otherwise that job fails with open .github/ci/compose.perf.yaml: no such file. Copy .github/ci/compose.perf.yaml from step 3 into a new .github/ci/ folder (the site does not publish folders whose name starts with a dot, so it cannot be downloaded), and download load/budget.js (or copy it from step 3 too):

mkdir -p .github/ci          # then paste compose.perf.yaml from step 3 into it
curl -fsSL -o load/budget.js https://insper.github.io/platform/2027.1/hands-on/11/code/load/budget.js

Then commit and push:

git add .github services/catalog load/budget.js
git commit -m "lab 11: CI with tests"
git push

On GitHub, open your repository → Actions → CI. You should see the run start within seconds, seven test (…) jobs in parallel, then performance budget (step 3) and, on main, image (catalog) (step 2). A green check on the commit means every job passed; click a job to read its log.

Why test on pull requests and on main?

The pull-request run tests the branch as it was written; the run on main tests the result of the merge, which can differ (another pull request merged in between). Only the run on main publishes images — the artifact that will be deployed comes from the tested merge result.

Next: Image and supply chain