Como eu usava antes
Antes do Testcontainers, a abordagem padrão era mockar dependências. Mockar um driver de banco de dados ou um cliente Redis funciona para testes unitários, mas é frágil para testes de integração. Você testa o mock, não o comportamento real. Já vi muitas vezes testes passarem com mocks e falharem em produção por diferenças sutis de serialização, timeouts ou semântica de comandos. Rodar um banco de dados compartilhado também é problemático: estado sujo entre execuções, conflitos de schema e a necessidade de limpar bases manualmente.O que é o Testcontainers
O Testcontainers é um pacote que oferece APIs para iniciar e interagir com contêineres Docker diretamente do seu código de teste. Cada módulo encapsula um serviço. Por exemplo, o móduloPostgreSqlContainer expõe métodos para obter a string de conexão, usuário, senha, etc., já configurados. Você não precisa lidar com scripts shell ou arquivos docker-compose para subir dependências de teste.
Instalação
Instale o pacote principal:npm install -D testcontainers
Depois, adicione o módulo específico do serviço que você precisa:
npm install -D @testcontainers/postgresql
Módulos existem para PostgreSQL, MySQL, Redis, Kafka, Elasticsearch, MongoDB, entre outros. Você também pode criar contêineres genéricos com GenericContainer.
Primeiro exemplo: PostgreSQL
Aqui está um teste simples que sobe um PostgreSQL, cria uma tabela e insere dados:const { PostgreSqlContainer } = require('@testcontainers/postgresql'); const { Client } = require('pg'); describe('PostgreSQL Testcontainers', () => { let container; let client; beforeAll(async () => { container = await new PostgreSqlContainer() .withDatabase('testdb') .withUsername('testuser') .withPassword('testpass') .start(); client = new Client({ host: container.getHost(), port: container.getPort(), database: container.getDatabase(), user: container.getUsername(), password: container.getPassword(), }); await client.connect(); }); afterAll(async () => { await client.end(); await container.stop(); }); it('should create a table and insert a row', async () => { await client.query(` CREATE TABLE users ( id SERIAL PRIMARY KEY, name VARCHAR(50) ) `); await client.query(`INSERT INTO users (name) VALUES ('Alice')`); const res = await client.query(`SELECT * FROM users`); expect(res.rows).toHaveLength(1); expect(res.rows[0].name).toBe('Alice'); }); });
O ciclo é sempre o mesmo: inicia o contêiner, usa as credenciais expostas, executa o teste, derruba tudo. Nada de sujeira acumulada.
Configuração avançada
Pool de contêineres quentes
Por padrão, cada suíte de testes sobe e derruba um contêiner novo. Isso é seguro, mas em suítes muito grandes o cold start acumulado pode ser proibitivo. Para desenvolvimento local, use owithReuse:
container = await new PostgreSqlContainer() .withReuse() .withLabel('reuse-id', 'my-postgres') .start();
O contêiner fica rodando até ser explicitamente parado. Mas em CI, withReuse pode vazar estado entre pipelines diferentes. Para resolver isso com segurança, implemente um sistema de pool de contêineres quentes com tag por branch:
const branch = process.env.CI_COMMIT_BRANCH || 'local'; const reuseId = `postgres-${branch}-${require('crypto').randomBytes(4).toString('hex')}`; container = await new PostgreSqlContainer() .withReuse() .withLabel('branch', branch) .withLabel('reuse-id', reuseId) .start();
Associado a um cronjob que roda docker container prune --filter "label=branch=<branch>" após o merge, você mantém um pool de contêineres quentes por branch ativa. É a diferença entre esperar 10 segundos e 200 milissegundos para obter uma conexão de banco.
Wait strategies
Por padrão, o Testcontainers espera que a porta do serviço esteja disponível. Mas muitos serviços expõem a porta antes de estarem realmente prontos. Por isso, use wait strategies explícitas:const { Wait } = require('testcontainers'); container = await new PostgreSqlContainer() .withWaitStrategy( Wait.forLogMessage('database system is ready to accept connections') ) .start();
Isso evita falhas intermitentes no início dos testes.
Testes paralelos com Jest
Se você usa--maxWorkers > 1 no Jest, precisa garantir que portas e aliases de rede não colidam entre workers diferentes.
O Testcontainers resolve portas aleatórias automaticamente. Porém, aliases de rede são estáticos — dois workers tentando criar o mesmo alias na mesma rede Docker vão conflitar. A solução é tornar os aliases únicos por worker:
const workerId = process.env.JEST_WORKER_ID || '0'; const networkAlias = `db-${workerId}`; const pgContainer = await new PostgreSqlContainer() .withNetwork(network) .withNetworkAliases(networkAlias) .start();
Assim, cada worker do Jest tem seu próprio namespace na rede Docker, mantendo total isolamento mesmo em execução paralela.
Rede customizada
Se você precisa que múltiplos contêineres se comuniquem, crie uma rede Docker compartilhada:const { Network } = require('testcontainers'); const network = await new Network().start(); const pgContainer = await new PostgreSqlContainer() .withNetwork(network) .withNetworkAliases('db') .start();
Variáveis de ambiente
Qualquer opção extra pode ser injetada viawithEnvironment:
container = await new GenericContainer('redis') .withEnvironment({ REDIS_PASSWORD: 'secret' }) .start();
Integração com ORMs: migrations programáticas
Em vez de confiar em um banco pré-configurado ou scripts externos, suba o schema programaticamente dentro dobeforeAll. Isso garante que o schema esteja sempre sincronizado com a versão do código sob teste.
Exemplo com Knex:
const knex = require('knex'); beforeAll(async () => { container = await new PostgreSqlContainer().start(); const db = knex({ client: 'pg', connection: { host: container.getHost(), port: container.getPort(), user: container.getUsername(), password: container.getPassword(), database: container.getDatabase(), }, }); await db.migrate.latest(); await db.seed.run(); // injete essa instância nos testes global.__db__ = db; });
Se usa TypeORM, faça await dataSource.runMigrations(). Se usa Prisma, prisma migrate deploy seguido de prisma db seed. O ponto central é: o ciclo de vida do schema é parte do teste, não um pré-requisito externo. Isso fecha a porta para o clássico “esqueci de rodar a migration no ambiente de teste”.
Snapshots determinísticos com browser-container
Testcontainers não serve só para bancos. O módulobrowser-container permite rodar navegadores reais — Chromium, Firefox — dentro de contêineres, com total isolamento.
Combine isso com jest-image-snapshot e você terá testes visuais determinísticos, sem depender de fontes, resoluções de tela ou configurações do sistema operacional do runner:
const { BrowserContainer } = require('@testcontainers/browser'); const { toMatchImageSnapshot } = require('jest-image-snapshot'); expect.extend({ toMatchImageSnapshot }); let browserContainer; let browser; beforeAll(async () => { browserContainer = await new BrowserContainer() .withImage('chromium') .start(); const wsEndpoint = browserContainer.getWebSocketUrl(); browser = await chromium.connect(wsEndpoint); }); afterAll(async () => { await browser.close(); await browserContainer.stop(); }); it('renders the dashboard correctly', async () => { const page = await browser.newPage(); await page.goto('http://localhost:3000/dashboard'); const screenshot = await page.screenshot(); expect(screenshot).toMatchImageSnapshot({ failureThreshold: 0.01, failureThresholdType: 'percent', }); });
Como o contêiner do navegador é imutável e idêntico em qualquer máquina, os snapshots são perfeitamente reproduzíveis entre desenvolvedores e CI. Chega de testes de screenshot que “quebram misteriosamente” porque alguém atualizou o Chrome local.
Teste real: app Node.js com PostgreSQL e Redis
Suponha um app Express que depende de PostgreSQL e Redis. Seu teste de integração sobe ambos os contêineres, aplica as migrations, popula dados e faz requisições HTTP reais contra o app:beforeAll(async () => { pgContainer = await new PostgreSqlContainer().start(); redisContainer = await new GenericContainer('redis:7') .withExposedPorts(6379) .start(); process.env.DATABASE_URL = `postgresql://${pgContainer.getUsername()}:${pgContainer.getPassword()}@${pgContainer.getHost()}:${pgContainer.getPort()}/${pgContainer.getDatabase()}`; process.env.REDIS_URL = `redis://${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}`; // Rode as migrations e inicie o servidor... const app = require('../app'); server = app.listen(0); });
Isso lhe dá confiança de que o sistema todo funciona, não apenas partes isoladas.
Testcontainers na CI
No GitHub Actions, por exemplo, você só precisa garantir que o Docker esteja disponível — ele já vem pré-instalado nos runnersubuntu-latest. Nenhuma configuração especial é necessária.
Um passo típico:
- name: Run tests run: npm test
Sem services extras no workflow, sem docker-compose. Tudo está no código.
Limitações e cuidados
- Performance: Subir um contêiner é rápido, mas não instantâneo. O pool de contêineres quentes com tag por branch resolve isso para suítes grandes.
- Sobrecarga de recursos: Vários contêineres simultâneos podem pesar em CI. Monitore o consumo de memória e ajuste o
maxWorkersde acordo. - Docker em Docker (DinD): Se você usa contêineres para rodar seus testes (ex.: runners customizados), pode precisar configurar o socket do Docker ou usar DinD, mas em runners padrão funciona sem problemas.
- Limpeza em paralelo: Mesmo com
afterAll, se um worker do Jest for morto abruptamente, contêineres podem ficar órfãos. Combine aliases únicos por worker com umglobalTeardownque faça a limpeza como rede de segurança. - Snapshots visuais: Contêineres de navegador são pesados. Reserve-os para testes de regressão visual crítica e execute-os em um job separado do CI.
Conclusão
O Testcontainers é uma ferramenta que considero indispensável para testes de integração em Node.js. Ele remove a desculpa de que “testes de integração são difíceis de configurar” e aproxima o ambiente de teste da produção real. As estratégias que discutimos — pool de contêineres quentes, migrations programáticas, aliases únicos para paralelismo e snapshots determinísticos via browser-container — são o salto de maturidade que separa um teste que “funciona” de um teste que escala em times grandes e pipelines complexas. Se você ainda mocka banco de dados ou depende de instâncias compartilhadas para testes, experimente o Testcontainers. E quando o fizer, vá direto para essas práticas avançadas. A qualidade, a previsibilidade e a velocidade do seu pipeline de testes vão melhorar significativamente. Continue aprendendo Sincronizando PostgreSQL e Turbopuffer com Puffgres – A integração de inteligência artificial em aplicações modernas — seja para buscas semânticas, sistemas de Geração Aumentada por Recuperação (RAG) ou motores de recomendação — introduziu um desafio arquitetural complexo: a sincronização de dados. Como manter seu banco de dados relacional primário e seu banco de dados vetorial perfeitamente alinhados sem comprometer a performance ou introduzir inconsistências3 Técnicas para Reduzir Consumo de Tokens no Claude Code e Codex
