No artigo anterior, mostrei como escrever testes de integração em Rust usando Testcontainers contra Postgres e Redis reais. Funciona bem, mas tem um detalhe que só apareceu quando levei o padrão "container singleton" para produção: se você não tomar cuidado, cada cargo test deixa um container Postgres zumbi rodando na sua máquina, para sempre.
Não é um bug raro. É uma consequência direta de como static funciona em Rust, combinada com o fato de que testcontainers-rs — ao contrário das versões Java e Go — não tem um reaper estilo ryuk. Vale entender por quê antes de copiar o padrão de singleton do artigo anterior para o seu projeto.
O padrão que parecia certo
A ideia é simples: em vez de subir um container novo a cada teste, sobe um container Postgres uma vez por binário de teste e reaproveita para todos, criando um banco de dados isolado por teste dentro dele.
static CONTAINER: OnceCell<(ContainerAsync<Postgres>, u16)> = OnceCell::const_new();
async fn container_port() -> u16 {
let (_container, port) = CONTAINER
.get_or_init(|| async {
let container = Postgres::default()
.with_tag("16-alpine")
.start()
.await
.expect("postgres container started");
let port = container
.get_host_port_ipv4(5432)
.await
.expect("postgres port");
(container, port)
})
.await;
*port
}
Cada teste continua isolado — só que em vez de pagar o boot completo do container (tipicamente de 1 a 3 segundos), ele paga apenas o custo de um CREATE DATABASE. Numa suíte com dezenas de testes, a diferença é enorme.
Só que tem um problema escondido nesse static.
static nunca é dropado — nem quando o processo termina
Em Rust, valores armazenados em static nunca chamam seus destrutores. Isso não depende de como o processo termina — não importa se o main retorna normalmente ou se alguém chama std::process::exit. É uma garantia da linguagem: destrutores de static simplesmente não rodam.
O Drop de ContainerAsync existe e faz o trabalho certo — ele chama docker rm no container. O problema é que, guardado num OnceCell static, esse Drop nunca é executado. Nada nunca remove o container.
Em Java e Go, isso não costuma doer porque o Testcontainers roda um container companheiro chamado ryuk: um reaper que fica de olho na conexão do processo de teste e remove os recursos órfãos quando ela cai, independente de qualquer Drop/finally/defer ter rodado. testcontainers-rs não implementa ryuk. Sem isso, e sem Drop de static, não existe rede de segurança.
A forma mais direta de confirmar isso é rodar a suíte de testes e contar containers antes e depois:
docker ps --filter "ancestor=postgres:16-alpine" --format "{{.ID}}" | wc -l
Sem correção, esse número só cresce a cada cargo test.
O fix: um destructor de verdade, fora do Rust
Como não dá para confiar em Drop, a solução é interceptar o encerramento real do processo — no nível do runtime C, não do Rust. É exatamente para isso que serve a crate dtor (a parte de destructors que saiu da ctor a partir da versão 1.0): ela registra uma função na seção .fini_array do binário, que o sistema operacional executa no encerramento do processo, sem depender de nenhuma garantia da linguagem Rust.
O detalhe importante: dentro desse hook não existe runtime tokio ativo, então não dá para simplesmente chamar o Drop assíncrono do ContainerAsync de novo (ele usa tokio::runtime::Handle::current() internamente e entra em pânico sem um runtime). A saída é guardar só o ID do container como String e remover o container via docker rm -f diretamente, de forma síncrona:
static CONTAINER_ID: OnceLock<String> = OnceLock::new();
// ... dentro do get_or_init do container:
CONTAINER_ID.set(container.id().to_owned()).expect("set once");
#[dtor::dtor]
unsafe fn stop_shared_container() {
if let Some(id) = CONTAINER_ID.get() {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", id])
.output();
}
}
A crate exige que a função seja marcada unsafe fn — o próprio código antes de main não tem as garantias normais do runtime Rust, e a macro avisa isso via deprecation warning se você não marcar. Depois dessa mudança, rodar a suíte inteira e contar containers com docker ps mostra zero sobras, em qualquer cenário: sucesso, teste que falha, ou processo interrompido no meio.
Pegadinha extra: o singleton é por binário, não por suíte
Se você vem de Java ou Go, é fácil assumir que "container singleton" significa um único container para a rodada inteira de cargo test. Não é isso que acontece.
O Cargo compila cada arquivo em tests/ como um binário de teste separado, e os testes unitários dentro de src/ (os #[cfg(test)] inline) compilam para outro binário totalmente à parte. Um static vive no escopo de um processo — então "singleton" aqui quer dizer um container por binário, não um container por projeto. Um projeto com testes unitários espalhados pelo src/ e um tests/integration.rs vai, no mínimo, subir dois containers por rodada completa de cargo test, não um. Isso é esperado e não é um bug, mas vale saber antes de estranhar ver mais de um container no docker ps no meio da suíte.
Banco por teste em vez de transação por teste
O artigo anterior usa rollback de transação para isolar testes — funciona bem quando cada teste segura uma única conexão. O problema aparece se sua aplicação usa um pool de conexões de verdade (diesel-async + deadpool, por exemplo): o pool entrega conexões arbitrárias, então não existe "a" conexão do teste para dar rollback.
A alternativa que combina bem com o container singleton: em vez de uma transação, cada teste cria seu próprio banco dentro do container compartilhado.
let db_name = format!("test_{}", DB_COUNTER.fetch_add(1, Ordering::Relaxed));
diesel::PgConnection::establish(&admin_url)
.expect("admin connection")
.batch_execute(&format!(r#"CREATE DATABASE "{db_name}""#))
.expect("create test database");
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/{db_name}");
diesel::PgConnection::establish(&url)
.expect("migration connection")
.run_pending_migrations(MIGRATIONS)
.expect("migrations");
let pool = DbPool::new(&DatabaseSettings { url }).expect("test db pool");
O custo de um CREATE DATABASE é muito menor que o de subir um container novo, e o isolamento continua total: cada teste tem seu próprio schema, sem estado compartilhado entre eles, sem depender de qual conexão o pool decidiu entregar.
Resultado
Numa suíte real com 65 testes unitários e 17 de integração, o binário de testes unitários passou a rodar em cerca de 6 segundos e o de integração em cerca de 3 — cada um pagando o boot do container uma única vez, não uma vez por teste. E depois de cada rodada, docker ps mostra zero containers órfãos.
O padrão de container singleton do artigo anterior continua sendo o caminho certo. Só que "singleton" em Rust, sem o reaper que outras linguagens têm de graça, exige um destructor explícito de verdade — e vale saber disso antes de descobrir pelo docker system df crescendo silenciosamente no seu disco.
Recursos adicionais:
