Destino Dev

Blog

Testcontainers e GitHub Actions: testar imagens Docker na CI corretamente

Por John Pires · 11 de março de 2026

Testcontainers e GitHub Actions: testar imagens Docker na CI corretamente

Motivacao e trade-offs

Por que testar imagens Docker na CI

Testes de integração que envolvem serviços externos (banco, broker, cache) são críticos para evitar regressões que só aparecem em integração. Testcontainers facilita esse fluxo ao orquestrar containers para a suíte de testes, garantindo que cada execução tenha um ambiente isolado e previsível. No entanto, rodar containers na CI introduz trade-offs: tempo de execução maior, consumo de recursos e possíveis instabilidades relacionadas a rede e pull de imagens.

Trade-offs práticos e decisões de arquitetura

Entre as decisões mais relevantes estão: executar Testcontainers diretamente no runner do GitHub Actions ou usar docker-compose/dind; separar testes unitários e de integração em jobs distintos; e optar por imagens oficiais ou imagens customizadas. Cada escolha impacta build time, custo e complexidade de troubleshooting. A regra prática: prefira simplicidade e previsibilidade — evite hacks que funcionam localmente mas quebram na CI.

Escolha: tempo vs estabilidade. Se sua suíte de integração sobe múltiplos serviços complexos, isole em um job próprio e configure caches/artefatos para reduzir tempo total.

Neste artigo faço code-review: mostrarei o jeito errado (com exemplos reais de anti-patterns) e depois a correção, incluindo workflows do GitHub Actions e testes Java com Testcontainers.

Fundamentos tecnicos

Como o Testcontainers funciona

Testcontainers usa a API do Docker para criar containers efêmeros, redes e volumes. Em Java, classes como PostgreSQLContainer encapsulam a criação e os detalhes de conexão. Testcontainers cria um container de suporte chamado Ryuk que faz cleanup; em CI, problemas com permissões ou sockets Docker explicam muitas falhas.

Diferenças entre estratégias na CI

Existem três abordagens comuns para rodar containers na CI: 1) usar o Docker Engine do runner (recomendado no GitHub Actions com runners ubuntu), 2) usar Docker-in-Docker (dind) — requer privilege e é mais frágil, 3) usar serviços em docker-compose externamente e apontar a suíte de testes para eles. Cada uma tem implicações na segurança e performance.

Erros comuns: assumir que runner sempre terá Docker configurado da mesma forma que sua máquina local; hardcode de portas; não aguardar readiness dos serviços; não limpar imagens/containers antigos e sobrecarregar runner.

Exemplo 1: caso simples

O jeito errado (code-review)

Anti-pattern: colocar um teste que depende do Postgres e esperar que a imagem já esteja rodando no runner. Código simplista que assume porta 5432 localmente e usa Thread.sleep() para esperar.

// O que faz: tento conectar em Postgres assumindo que ele existe em localhost:5432 (JEITO ERRADO)
import org.junit.jupiter.api.Test;
import java.sql.*;

public class BadPostgresIT {
    @Test
    public void testAssumesPostgres() throws Exception {
        Thread.sleep(5000); // espera fixa
        Connection c = DriverManager.getConnection("jdbc:postgresql://localhost:5432/test", "test", "test");
        // faz asserts
        c.close();
    }
}

O problema: flakiness (espera fixa), dependência do ambiente do runner, portas fixas e nenhuma garantia de isolamento. Isso costuma passar localmente e falhar na CI.

O jeito certo (usando Testcontainers)

Use Testcontainers para criar um Postgres efêmero. Ele cuida do lifecycle, fornece a URL JDBC dinâmica e integra com JUnit. Isso elimina dependências do ambiente do runner.

// O que faz: cria um container Postgres isolado para a suíte e usa a JDBC URL provida por Testcontainers
import org.testcontainers.containers.PostgreSQLContainer;
import org.junit.jupiter.api.*;
import java.sql.*;

public class PostgresIT {
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13-alpine")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");

    @BeforeAll
    static void start() { postgres.start(); }

    @AfterAll
    static void stop() { postgres.stop(); }

    @Test
    void testInsertAndSelect() throws Exception {
        try (Connection conn = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
            conn.createStatement().execute("CREATE TABLE IF NOT EXISTS items(id serial primary key, name text)");
            conn.createStatement().execute("INSERT INTO items(name) VALUES('a')");
            var rs = conn.createStatement().executeQuery("SELECT count(*) FROM items");
            rs.next();
            Assertions.assertEquals(1, rs.getInt(1));
        }
    }
}

Resultado: testes determinísticos e portáteis. Testcontainers baixa a imagem quando necessário e cria a rede automaticamente. No GitHub Actions, este padrão funciona em runners linux sem configuração extra.

Workflow minimal do GitHub Actions (funcional)

Contexto: Workflow que roda os testes Maven com Testcontainers em um runner Ubuntu. Não usa dind — o runner já expõe o Docker Engine que o Testcontainers utiliza.

# O que faz: roda mvn test no runner ubuntu, o Testcontainers irá subir imagens Docker quando invocado
name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'
      - name: Build and test
        run: mvn -B -DskipTests=false test

Após a execução, Testcontainers terá criado o container Postgres para os testes e o Ryuk cuidará do cleanup. Se ocorrerem falhas de pull de imagem, veja troubleshooting adiante.

Exemplo 2: caso avancado do mundo real

Requisitos do caso

Aplicação que depende de PostgreSQL, Kafka e Redis. Os testes precisam validar fluxos que passam dados entre microserviços e consumidores Kafka. Queremos execução paralela e tempos de set-up razoáveis na CI.

Jeito errado (anti-patterns observados em PRs)

Errado 1: subir tudo com sleeps e rodar testes enquanto containers nem sempre estão prontos. Errado 2: executar build de imagens dentro do mesmo job sem cache, causando timeout. Errado 3: usar dind com permissões elevadas sem necessidade.

# Anti-pattern: docker-compose up seguido de sleep grande e sem checks de readiness (JEITO ERRADO)
docker-compose -f ci/docker-compose.test.yml up -d --build
sleep 30
./run-tests.sh

Isso gera flakiness e desperdício de tempo. O correto é usar healthchecks e/ou scripts de espera que checam portas/endpoints e logs dos containers.

Solução recomendada: Testcontainers + docker-compose (workflow e scripts)

Abordagem: usar docker-compose local (em CI) para reduzir tempo de startup quando precisamos imagens customizadas, e usar Testcontainers para testes que controlam lifecycle. Combine com um script wait-for-services que testa readiness via TCP/HTTP e topics Kafka.

# Exemplo parcial de docker-compose.test.yml (o que faz: define serviços usados nos testes)
version: '3.8'
services:
  postgres:
    image: postgres:13-alpine
    environment:
      POSTGRES_DB: test
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "test"]
      interval: 5s
      retries: 5

  redis:
    image: redis:6-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 5

  kafka:
    image: bitnami/kafka:3
    environment:
      - KAFKA_BROKER_ID=1
      - KAFKA_LISTENERS=PLAINTEXT://:9092
      - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
    depends_on:
      zookeeper:
        condition: service_healthy

  zookeeper:
    image: bitnami/zookeeper:3
    environment:
      ALLOW_ANONYMOUS_LOGIN: "yes"
    healthcheck:
      test: ["CMD", "echo", "ruok", "|", "nc", "-vz", "localhost", "2181"]
      interval: 5s
      retries: 5

Depois de definir o compose com healthchecks, usamos um script de espera que verifica health status e portas específicas. Isso é mais robusto que sleep fixo.

# wait-for-services.sh (o que faz: espera até que portas e endpoints estejam prontos)
#!/usr/bin/env bash
set -e

# espera TCP
wait_port(){
  local host=$1; local port=$2; local retries=30
  until nc -z $host $port; do
    ((retries--)) || { echo "Timeout waiting $host:$port"; exit 1; }
    sleep 1
  done
}

wait_port localhost 5432  # postgres
wait_port localhost 6379  # redis
wait_port localhost 9092  # kafka

echo "All services up"

Workflow GitHub Actions (avançado): construa imagens com cache, suba compose, espere readiness e execute testes de integração em um job separado.

# Workflow parcial (o que faz: build cache, docker-compose up, espera e roda testes de integração)
name: CI Integration
on: [push]

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      - name: Cache Docker layers
        uses: actions/cache@v4
        with:
          path: /tmp/.buildx-cache
          key: ${{ runner.os }}-buildx-${{ github.sha }}
      - name: Start docker-compose services
        run: |
          docker-compose -f ci/docker-compose.test.yml up -d --build
      - name: Wait for services
        run: |
          chmod +x ci/wait-for-services.sh
          ci/wait-for-services.sh
      - name: Run integration tests
        run: mvn -B -DskipTests=false -Pintegration test
      - name: Tear down
        if: always()
        run: docker-compose -f ci/docker-compose.test.yml down --volumes --remove-orphans

Resultado: menos flakiness, imagens reaproveitadas quando possível, e jobs mais previsíveis. Evitamos dind e privilégios extras, usando os recursos padrão do runner.

Debugging e troubleshooting

Erros comuns e como diagnósticar

Falha ao puxar imagem: normalmente network timeout ou rate limit do Docker Hub. Solução: configurar cache de imagens na organização, usar registro privado, ou usar imagens base menores (alpine). Também considere usar GHCR para artifacts internos.

Permissão negada ao acessar socket Docker: isso aparece quando Testcontainers não encontra /var/run/docker.sock ou o runner não tem Docker Engine. Em runners ubuntu-latest do GitHub Actions, isso é raro; em runners self-hosted, verifique permissões e se o serviço docker está rodando como esperado.

Logs, Ryuk e timeouts

Testcontainers inicia Ryuk para cleanup. Se Ryuk não iniciar porque o runner bloqueia criação de processos privilegiados, o Testcontainers pode falhar. Habilite logs detalhados (testcontainers logs) e aumente timeouts quando necessário. Para ambientes onde Ryuk não é permitido, configure org.testcontainers.reuse.enable=true com cautela em runners controlados.

Dica: habilite DEBUG para org.testcontainers no SLF4J para obter as chamadas ao Docker e entender pulls/healthchecks.

Outra técnica útil: coletar docker ps -a e docker images no step de teardown quando um job falha — ajuda a ver containers órfãos e imagens que consumiram espaço.

Checklist rápido de troubleshooting

  1. Verifique se o runner possui Docker Engine ativo.
  2. Confirme se as imagens requeridas podem ser puxadas (rate limits / auth).
  3. Use healthchecks e scripts de espera em vez de sleeps fixos.
  4. Separe testes unitários e de integração em jobs diferentes.
  5. Capture logs dos containers no teardown para análises posteriores.

Conclusao pratica

Testcontainers é a ferramenta correta para testes de integração que exigem containers efêmeros. No CI com GitHub Actions, prefira usar o Docker Engine disponível no runner, evite dind salvo se necessário, e separe testes de integração em jobs distintos para reduzir impacto. Sempre substitua sleeps por healthchecks e scripts de espera, configure cache de imagens quando possível e colete logs automaticamente em falhas.

Resumo das recomendações práticas:

  • Use Testcontainers para isolamento e lifecycle automático dos serviços.
  • Evite hardcode de portas; use as URLs dinâmicas fornecidas pelo framework.
  • Separe pipelines (unit vs integration), use healthchecks e scripts de wait.
  • Monitore pull failures e rate limits; considere GHCR ou caching de imagens.

Aplicando essas práticas você reduz flakiness, acelera feedback loops e torna os pipelines mais previsíveis. Em code reviews, procure anti-patterns descritos aqui e exija readiness explícito e cleanup no workflow.

Por fim, se a sua suíte crescer demais, considere migração parcial para testes de contrato, mocks para cenários caras e execução de suites pesadas em runners self-hosted com recursos dedicados.

Assuntos: Docker, github-actions, testcontainers, ci-cd, DevOps

Leia também