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
- Verifique se o runner possui Docker Engine ativo.
- Confirme se as imagens requeridas podem ser puxadas (rate limits / auth).
- Use healthchecks e scripts de espera em vez de sleeps fixos.
- Separe testes unitários e de integração em jobs diferentes.
- 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
Testes em CI com GitHub Actions e Docker Compose para Postgres e Redis
Aprenda a rodar testes de integração no CI usando GitHub Actions e Docker Compose com Postgres e Redis. Guia passo a passo para iniciantes: arquivos…
Validando assinaturas de imagens Docker no Kubernetes com cosign e OPA Gatekeeper
Guia prático para validar assinaturas de imagens Docker no Kubernetes usando cosign e OPA Gatekeeper: padrões, exemplos com CI (GitHub Actions)…
Vault + GitHub Actions (OIDC): secrets dinâmicos e melhores práticas
O objetivo é eliminar credenciais estáticas armazenadas no repositório ou em secrets do GitHub. Em vez disso, o pipeline solicita um token OIDC curto…
Escanear imagens Docker no CI com Trivy e GitHub Actions — falhe por CVSS
Aprenda a escanear imagens Docker no CI com Trivy e GitHub Actions e a fazer o pipeline falhar quando uma vulnerabilidade atingir um limiar CVSS. Passo a…
GitHub Actions: build, testar em container e publicar imagem no GHCR
Pipeline prática: build de imagem Docker, executar testes no container carregado e publicar no GHCR usando GitHub Actions. Código e workflow para iniciar e…
Docker Buildx + GitHub Actions: publicar imagens multi-arch (ARM/x86) corretamente
Guia prático para publicar imagens Docker multi-arch (arm64/x86) usando Docker Buildx e GitHub Actions. Mostro o jeito errado, o certo, workflows, debug e…