Pular para conteúdo

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.

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);
    }
}

O pom.xml do catalog ganha as dependências de teste (e, para o passo 5, o registry do Prometheus). O arquivo completo:

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>
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):

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 ../..

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.

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

git add .github services/catalog load/budget.js
git commit -m "lab 11: CI with tests"
git 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.

Próximo: Imagem e cadeia de suprimentos