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.
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:
<?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):
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.575 s -- in boxoffice.catalog.CatalogApiTest
[INFO] BUILD SUCCESScd ../..
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.
# 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:
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.