1. Testes e CI
Um pipeline só é tão bom quanto os testes que ele roda. Este passo adiciona o primeiro teste de integração do BoxOffice — o catalog real contra um PostgreSQL real e um Redis real, iniciados pelo Testcontainers — e o workflow do GitHub Actions que o roda em todo push e pull request. Todos os comandos rodam na raiz do seu repositório boxoffice.
Um teste de integração com Testcontainers
Mocks testam o que você acha que o banco faz. O Testcontainers sobe a coisa real no Docker durante o teste: migrações do Flyway, mapeamentos JPA, SQL, o cache Redis e o formato JSON de erro são todos exercitados exatamente como em produção. O @ServiceConnection do Spring Boot aponta o datasource e o cliente Redis para os contêineres — nenhuma URL para configurar.
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);
}
}
O pom.xml do catalog ganha as dependências de teste (e, para o passo 5, o registry do Prometheus). O arquivo completo:
<?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>
| Dependência | Por quê |
|---|---|
spring-boot-starter-webmvc-test | JUnit 5, AssertJ, o suporte de testes do Spring para aplicações MVC |
spring-boot-starter-restclient-test | TestRestTemplate, um cliente HTTP real contra a porta aleatória |
spring-boot-testcontainers | @ServiceConnection: o Boot lê host/porta/credenciais dos contêineres |
testcontainers-postgresql, testcontainers-junit-jupiter | o módulo PostgreSQL e o ciclo de vida @Testcontainers/@Container (Testcontainers 2) |
micrometer-registry-prometheus | /actuator/prometheus, usado pela análise do canary do passo 5 |
A propriedade tomcat.version substitui o Tomcat que o Spring Boot 4.1.1 traz (11.0.24): o gate do Trivy do passo 2 o rejeita por vulnerabilidades CRITICAL com correção. Remova a propriedade quando o Dependabot atualizar o parent para uma versão com um Tomcat mais novo.
Rode-o localmente (precisa do Docker rodando, e de JDK 21 + Maven do lab 0):
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.575 s -- in boxoffice.catalog.CatalogApiTest
[INFO] BUILD SUCCESScd ../..
Você deve ver Tests run: 3, Failures: 0 e BUILD SUCCESS; a primeira execução também baixa as imagens postgres:17 e redis:8-alpine. Se você não tem JDK 21 localmente, pule este comando: o runner do CI executa exatamente o mesmo.
Testes não podem depender da ordem nem de dados compartilhados
O teste lê o evento 1 (criado pela migração de seed) e só falha ao criar um evento, então não deixa estado para trás. Testes que criam dados devem usar valores únicos (um UUID no título) ou rodar cada um numa transação que é desfeita.
O workflow
O GitHub Actions roda workflows (.github/workflows/*.yaml) feitos de jobs (cada um numa máquina virtual nova, um runner) feitos de steps (comandos de shell ou actions reutilizáveis). O CI deste lab tem três jobs; este passo explica o primeiro, os próximos passos explicam os outros.
# 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
| Parte | O que faz |
|---|---|
name: CI | o nome a que outros workflows se referem (deploy.yaml roda depois do "CI"; o lab 13 também). Não o renomeie |
on: push (main) / pull_request | toda mudança é testada antes e depois do merge; mudanças só em k8s/**, results/** ou Markdown não refazem o build |
permissions: contents: read | o token padrão só pode ler o repositório; o job image pede exatamente o que precisa |
concurrency | um novo push para a mesma branch cancela a execução que ficou obsoleta |
job test, matrix.service | um job paralelo por serviço Java; fail-fast: false reporta todo serviço quebrado, não só o primeiro |
actions/setup-java com cache: maven | baixa o JDK 21 e guarda o ~/.m2 em cache entre execuções (minutos → segundos) |
mvn -B verify | compila, roda os testes unitários e os do Testcontainers (o Docker está disponível nos runners Ubuntu do GitHub) |
upload-artifact em caso de falha | os relatórios do surefire de uma execução que falhou, para baixar na página da execução |
Serviços sem testes também rodam mvn verify: isso prova que compilam e empacotam. Adicione testes a eles conforme avança — a matriz já os inclui.
Push e acompanhe
O job performance deste workflow roda dois arquivos que o passo 3 explica: .github/ci/compose.perf.yaml e load/budget.js. Crie-os agora; caso contrário, esse job falha com open .github/ci/compose.perf.yaml: no such file. Copie o .github/ci/compose.perf.yaml do passo 3 para uma nova pasta .github/ci/ (o site não publica pastas cujo nome começa com ponto, então ele não pode ser baixado) e baixe o load/budget.js (ou copie-o também do passo 3):
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
Depois faça o commit e o push:
No GitHub, abra o seu repositório → Actions → CI. Você deve ver a execução começar em segundos, sete jobs test (…) em paralelo, depois performance budget (passo 3) e, na main, image (catalog) (passo 2). Um check verde no commit significa que todos os jobs passaram; clique num job para ler o log dele.
Por que testar nos pull requests e na main?
A execução do pull request testa a branch como ela foi escrita; a execução na main testa o resultado do merge, que pode ser diferente (outro pull request entrou no meio). Só a execução na main publica imagens — o artefato que vai para o deploy vem do resultado do merge testado.