Testes unitários verificam código isoladamente, mas testes de integração verificam se seu código funciona com dependências reais. Testcontainers inicia containers Docker reais para seus testes, eliminando mocks instáveis e capturando problemas de integração reais.
Testar com bancos de dados reais captura problemas que mocks não detectam: sintaxe SQL, constraints, transações e gerenciamento de conexões. Neste artigo, vou compartilhar como implementei testes de integração em uma aplicação de pagamentos que estou construindo em Rust.
Por Que Testcontainers?
Testcontainers resolve um problema fundamental no desenvolvimento: como testar código que depende de serviços externos (bancos de dados, caches, filas de mensagens) sem criar ambientes complexos ou usar mocks que não refletem o comportamento real.
Benefícios principais:
-
Ambiente real: Testa contra PostgreSQL, Redis, Kafka, etc. reais
-
Isolamento: Cada teste tem seus próprios containers
-
Cleanup automático: Containers são destruídos após os testes
-
Paralelização: Testes podem rodar em paralelo com segurança
-
CI/CD friendly: Funciona em qualquer ambiente com Docker
Configuração do Projeto
Primeiro, adicione as dependências necessárias ao seu Cargo.toml. Este
exemplo é baseado em uma API de pagamentos que estou desenvolvendo:
[package]
name = "payment-api"
version = "0.1.0"
edition = "2021"
[dependencies]
# Web framework
axum = "0.7"
tokio = { version = "1", features = ["full"] }
# Database
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
# Redis
redis = { version = "0.24", features = ["tokio-comp"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
# Testcontainers
testcontainers = "0.15"
testcontainers-modules = { version = "0.3", features = ["postgres", "redis"] }
# Test utilities
reqwest = { version = "0.11", features = ["json"] }
tokio-test = "0.4"
Configuração Básica de Containers
Crie um módulo common para compartilhar utilitários de teste:
// tests/common/mod.rs
// Utilitários compartilhados e configuração de containers
use testcontainers::{clients::Cli, Container, RunnableImage};
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::redis::Redis;
use sqlx::PgPool;
use std::sync::OnceLock;
// Cliente Docker singleton (reutilizado entre testes)
static DOCKER: OnceLock<Cli> = OnceLock::new();
fn docker() -> &'static Cli {
DOCKER.get_or_init(|| Cli::default())
}
/// Container PostgreSQL para testes
pub struct TestPostgres<'a> {
_container: Container<'a, Postgres>,
pub pool: PgPool,
pub url: String,
}
impl<'a> TestPostgres<'a> {
/// Inicia um container PostgreSQL e retorna o pool de conexões
pub async fn new() -> Self {
let docker = docker();
// Inicia o container PostgreSQL
let container = docker.run(Postgres::default());
// Obtém informações de conexão
let port = container.get_host_port_ipv4(5432);
let url = format!(
"postgres://postgres:postgres@localhost:{}/postgres",
port
);
// Cria pool de conexões
let pool = PgPool::connect(&url)
.await
.expect("Falha ao conectar ao PostgreSQL");
// Executa migrations
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Falha ao executar migrations");
Self {
_container: container,
pool,
url,
}
}
/// Limpa o banco de dados entre testes
pub async fn cleanup(&self) {
// Trunca todas as tabelas (mais rápido que derrubar o container)
sqlx::query("TRUNCATE users, posts, comments RESTART IDENTITY CASCADE")
.execute(&self.pool)
.await
.expect("Falha ao truncar tabelas");
}
}
/// Container Redis para testes
pub struct TestRedis<'a> {
_container: Container<'a, Redis>,
pub client: redis::Client,
pub url: String,
}
impl<'a> TestRedis<'a> {
pub async fn new() -> Self {
let docker = docker();
let container = docker.run(Redis::default());
let port = container.get_host_port_ipv4(6379);
let url = format!("redis://localhost:{}", port);
let client = redis::Client::open(url.as_str())
.expect("Falha ao criar cliente Redis");
Self {
_container: container,
client,
url,
}
}
/// Obtém conexão assíncrona
pub async fn connection(&self) -> redis::aio::MultiplexedConnection {
self.client
.get_multiplexed_async_connection()
.await
.expect("Falha ao obter conexão Redis")
}
/// Limpa todos os dados
pub async fn cleanup(&self) {
let mut conn = self.connection().await;
redis::cmd("FLUSHALL")
.query_async::<()>(&mut conn)
.await
.expect("Falha ao limpar Redis");
}
}
Testando Operações de Banco de Dados
Agora vamos testar um repositório de usuários com PostgreSQL real:
// tests/user_repository_test.rs
// Testes de integração para repositório de usuários
mod common;
use myapi::repository::UserRepository;
use myapi::models::CreateUser;
use uuid::Uuid;
#[tokio::test]
async fn test_create_user() {
// Arrange: Inicia container PostgreSQL
let postgres = common::TestPostgres::new().await;
let repo = UserRepository::new(postgres.pool.clone());
// Act: Cria um usuário
let create_user = CreateUser {
email: "[email protected]".to_string(),
username: "testusuario".to_string(),
password_hash: "hashed".to_string(),
};
let user = repo.create(create_user).await.expect("Falha ao criar usuário");
// Assert: Usuário foi criado com dados corretos
assert_eq!(user.email, "[email protected]");
assert_eq!(user.username, "testusuario");
assert!(user.id != Uuid::nil());
}
#[tokio::test]
async fn test_find_user_by_email() {
let postgres = common::TestPostgres::new().await;
let repo = UserRepository::new(postgres.pool.clone());
// Cria usuário primeiro
let create_user = CreateUser {
email: "[email protected]".to_string(),
username: "buscausuario".to_string(),
password_hash: "hashed".to_string(),
};
repo.create(create_user).await.unwrap();
// Busca por email
let found = repo.find_by_email("[email protected]").await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().email, "[email protected]");
}
#[tokio::test]
async fn test_user_email_uniqueness() {
let postgres = common::TestPostgres::new().await;
let repo = UserRepository::new(postgres.pool.clone());
// Cria primeiro usuário
let create_user = CreateUser {
email: "[email protected]".to_string(),
username: "usuario1".to_string(),
password_hash: "hashed".to_string(),
};
repo.create(create_user).await.unwrap();
// Tenta criar email duplicado
let duplicate = CreateUser {
email: "[email protected]".to_string(),
username: "usuario2".to_string(),
password_hash: "hashed".to_string(),
};
let result = repo.create(duplicate).await;
// Deve falhar com violação de constraint unique
assert!(result.is_err());
}
#[tokio::test]
async fn test_transaction_rollback() {
let postgres = common::TestPostgres::new().await;
let pool = postgres.pool.clone();
// Inicia transação
let mut tx = pool.begin().await.unwrap();
// Insere usuário dentro da transação
sqlx::query!(
"INSERT INTO users (id, email, username, password_hash) VALUES ($1, $2, $3, $4)",
Uuid::new_v4(),
"[email protected]",
"rollbackuser",
"hashed"
)
.execute(&mut *tx)
.await
.unwrap();
// Faz rollback da transação
tx.rollback().await.unwrap();
// Verifica que o usuário não foi persistido
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE email = $1")
.bind("[email protected]")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 0);
}
Testando Endpoints da API
Teste a API completa com banco de dados real:
// tests/api_test.rs
// Testes end-to-end da API com banco de dados real
mod common;
use axum::{Router, routing::get, routing::post};
use myapi::{routes, AppState};
use reqwest::StatusCode;
/// Inicia o servidor da API com banco de teste
async fn spawn_app(postgres: &common::TestPostgres<'_>) -> String {
// Constrói aplicação com banco de teste
let state = AppState {
db: postgres.pool.clone(),
};
let app = Router::new()
.nest("/api", routes::api_routes())
.with_state(state);
// Bind em porta aleatória
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.unwrap();
let addr = listener.local_addr().unwrap();
// Executa servidor em background
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{}", addr)
}
#[tokio::test]
async fn test_create_user_endpoint() {
let postgres = common::TestPostgres::new().await;
let base_url = spawn_app(&postgres).await;
let client = reqwest::Client::new();
// Cria usuário via API
let response = client
.post(&format!("{}/api/users", base_url))
.json(&serde_json::json!({
"email": "[email protected]",
"username": "apiuser",
"password": "secretpassword123"
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let user: serde_json::Value = response.json().await.unwrap();
assert_eq!(user["email"], "[email protected]");
assert!(user["id"].is_string());
}
#[tokio::test]
async fn test_get_user_endpoint() {
let postgres = common::TestPostgres::new().await;
let base_url = spawn_app(&postgres).await;
let client = reqwest::Client::new();
// Primeiro cria um usuário
let create_response = client
.post(&format!("{}/api/users", base_url))
.json(&serde_json::json!({
"email": "[email protected]",
"username": "getuser",
"password": "password123"
}))
.send()
.await
.unwrap();
let created: serde_json::Value = create_response.json().await.unwrap();
let user_id = created["id"].as_str().unwrap();
// Busca o usuário
let get_response = client
.get(&format!("{}/api/users/{}", base_url, user_id))
.send()
.await
.unwrap();
assert_eq!(get_response.status(), StatusCode::OK);
let user: serde_json::Value = get_response.json().await.unwrap();
assert_eq!(user["email"], "[email protected]");
}
#[tokio::test]
async fn test_user_not_found() {
let postgres = common::TestPostgres::new().await;
let base_url = spawn_app(&postgres).await;
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/users/{}", base_url, uuid::Uuid::new_v4()))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
Testando com Cache Redis
Redis é comumente usado para caching. Vamos testar o comportamento do cache:
// tests/cache_test.rs
// Testando comportamento de cache com Redis
mod common;
use myapi::cache::UserCache;
use myapi::models::User;
use uuid::Uuid;
use chrono::Utc;
#[tokio::test]
async fn test_cache_hit() {
let redis = common::TestRedis::new().await;
let cache = UserCache::new(redis.client.clone());
let user = User {
id: Uuid::new_v4(),
email: "[email protected]".to_string(),
username: "cacheduser".to_string(),
created_at: Utc::now(),
};
// Define cache
cache.set(&user).await.unwrap();
// Busca do cache
let cached = cache.get(user.id).await.unwrap();
assert!(cached.is_some());
assert_eq!(cached.unwrap().email, user.email);
}
#[tokio::test]
async fn test_cache_miss() {
let redis = common::TestRedis::new().await;
let cache = UserCache::new(redis.client.clone());
let result = cache.get(Uuid::new_v4()).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_cache_expiration() {
let redis = common::TestRedis::new().await;
let cache = UserCache::with_ttl(redis.client.clone(), 1); // TTL de 1 segundo
let user = User {
id: Uuid::new_v4(),
email: "[email protected]".to_string(),
username: "expiringuser".to_string(),
created_at: Utc::now(),
};
cache.set(&user).await.unwrap();
// Verifica que está em cache
assert!(cache.get(user.id).await.unwrap().is_some());
// Aguarda expiração
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// Deve ter expirado
assert!(cache.get(user.id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_cache_invalidation() {
let redis = common::TestRedis::new().await;
let cache = UserCache::new(redis.client.clone());
let user = User {
id: Uuid::new_v4(),
email: "[email protected]".to_string(),
username: "invalidateuser".to_string(),
created_at: Utc::now(),
};
cache.set(&user).await.unwrap();
cache.invalidate(user.id).await.unwrap();
let result = cache.get(user.id).await.unwrap();
assert!(result.is_none());
}
Execução de Testes em Paralelo
Um dos desafios ao rodar testes em paralelo é garantir isolamento. Aqui estão algumas estratégias:
// tests/parallel_test.rs
// Isolamento de testes para execução paralela
mod common;
use common::TestPostgres;
use sqlx::PgPool;
/// Usa transações para isolamento
async fn with_transaction<F, Fut, R>(pool: &PgPool, f: F) -> R
where
F: FnOnce(&mut sqlx::Transaction<'_, sqlx::Postgres>) -> Fut,
Fut: std::future::Future<Output = R>,
{
// Inicia transação
let mut tx = pool.begin().await.unwrap();
// Executa o teste dentro da transação
let result = f(&mut tx).await;
// Rollback - mudanças não persistem
tx.rollback().await.unwrap();
result
}
#[tokio::test]
async fn test_parallel_1() {
let postgres = TestPostgres::new().await;
// Usa isolamento por transação
with_transaction(&postgres.pool, |tx| async move {
sqlx::query!(
"INSERT INTO users (id, email, username, password_hash) VALUES ($1, $2, $3, $4)",
uuid::Uuid::new_v4(),
"[email protected]",
"parallel1",
"hash"
)
.execute(&mut **tx)
.await
.unwrap();
// Asserções do teste aqui
}).await;
}
#[tokio::test]
async fn test_parallel_2() {
let postgres = TestPostgres::new().await;
with_transaction(&postgres.pool, |tx| async move {
sqlx::query!(
"INSERT INTO users (id, email, username, password_hash) VALUES ($1, $2, $3, $4)",
uuid::Uuid::new_v4(),
"[email protected]",
"parallel2",
"hash"
)
.execute(&mut **tx)
.await
.unwrap();
// Asserções do teste aqui
}).await;
}
Estratégias de isolamento:
-
Transações: Cada teste roda dentro de uma transação que é revertida ao final
-
Containers dedicados: Cada teste inicia seu próprio container (mais lento)
-
Schemas separados: Cada teste usa um schema PostgreSQL diferente
-
Cleanup explícito: Truncar tabelas entre testes
Configuração Customizada de Containers
Você pode personalizar os containers conforme suas necessidades:
// tests/common/custom_containers.rs
// Configurações customizadas de containers
use testcontainers::{RunnableImage};
use testcontainers_modules::postgres::Postgres;
/// PostgreSQL com configuração específica
pub fn postgres_with_config() -> RunnableImage<Postgres> {
let postgres = Postgres::default();
RunnableImage::from(postgres)
.with_env_var(("POSTGRES_PASSWORD", "testpass"))
.with_env_var(("POSTGRES_DB", "testdb"))
.with_env_var(("POSTGRES_USER", "testuser"))
// Desabilita fsync para testes mais rápidos
.with_env_var(("POSTGRES_HOST_AUTH_METHOD", "trust"))
}
/// PostgreSQL com extensões
pub fn postgres_with_extensions() -> RunnableImage<Postgres> {
let postgres = Postgres::default()
.with_db_name("testdb")
.with_user("test")
.with_password("test");
RunnableImage::from(postgres)
}
/// Aguarda container estar pronto com health check customizado
pub async fn wait_for_postgres(port: u16) {
let url = format!("postgres://postgres:postgres@localhost:{}/postgres", port);
// Retenta até a conexão ter sucesso
for _ in 0..30 {
if sqlx::PgPool::connect(&url).await.is_ok() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
panic!("PostgreSQL não ficou pronto a tempo");
}
Testando com Múltiplos Containers
Testes que requerem múltiplos serviços (banco de dados + cache):
// tests/integration_test.rs
// Testes que requerem múltiplos serviços
mod common;
use common::{TestPostgres, TestRedis};
/// Teste de integração completo com banco de dados e cache
#[tokio::test]
async fn test_full_workflow() {
// Inicia ambos os containers
let postgres = TestPostgres::new().await;
let redis = TestRedis::new().await;
// Constrói estado da aplicação com ambas as conexões
let state = myapi::AppState {
db: postgres.pool.clone(),
cache: redis.client.clone(),
};
// Testa o workflow completo
let user_service = myapi::services::UserService::new(state);
// Cria usuário (escreve no DB)
let user = user_service
.create_user("[email protected]", "integrationuser", "password")
.await
.unwrap();
// Busca usuário (deve cachear no Redis)
let fetched1 = user_service.get_user(user.id).await.unwrap().unwrap();
assert_eq!(fetched1.email, "[email protected]");
// Busca novamente (deve usar cache)
let fetched2 = user_service.get_user(user.id).await.unwrap().unwrap();
assert_eq!(fetched2.id, user.id);
// Verifica que cache foi populado
let mut redis_conn = redis.connection().await;
let cached: Option<String> = redis::cmd("GET")
.arg(format!("user:{}", user.id))
.query_async(&mut redis_conn)
.await
.unwrap();
assert!(cached.is_some());
}
Fixtures de Teste
Para testes complexos, é útil ter dados de teste reutilizáveis:
// tests/fixtures.rs
// Dados de teste reutilizáveis
use myapi::models::{User, Post};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::Utc;
pub struct TestFixtures {
pub users: Vec<User>,
pub posts: Vec<Post>,
}
impl TestFixtures {
/// Popula banco de dados com dados de teste
pub async fn seed(pool: &PgPool) -> Self {
let mut users = Vec::new();
let mut posts = Vec::new();
// Cria usuários
for i in 0..5 {
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, username, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, username, created_at
"#,
Uuid::new_v4(),
format!("user{}@example.com", i),
format!("user{}", i),
"hashed",
Utc::now()
)
.fetch_one(pool)
.await
.unwrap();
users.push(user);
}
// Cria posts para cada usuário
for user in &users {
for j in 0..3 {
let post = sqlx::query_as!(
Post,
r#"
INSERT INTO posts (id, author_id, title, content, created_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, author_id, title, content, created_at
"#,
Uuid::new_v4(),
user.id,
format!("Post {} by {}", j, user.username),
format!("Conteúdo do post {}", j),
Utc::now()
)
.fetch_one(pool)
.await
.unwrap();
posts.push(post);
}
}
Self { users, posts }
}
}
#[tokio::test]
async fn test_with_fixtures() {
let postgres = common::TestPostgres::new().await;
let fixtures = TestFixtures::seed(&postgres.pool).await;
// Usa dados das fixtures nos testes
assert_eq!(fixtures.users.len(), 5);
assert_eq!(fixtures.posts.len(), 15);
// Testa queries contra dados populados
let user_posts: Vec<Post> = sqlx::query_as!(
Post,
"SELECT id, author_id, title, content, created_at FROM posts WHERE author_id = $1",
fixtures.users[0].id
)
.fetch_all(&postgres.pool)
.await
.unwrap();
assert_eq!(user_posts.len(), 3);
}
Melhores Práticas
| Prática | Motivo |
|---|---|
| Use containers reais | Captura problemas reais de integração |
| Isole testes | Previne interferência entre testes |
| Limpe entre testes | Estado inicial consistente |
| Reutilize cliente Docker | Startup de containers mais rápido |
| Execute migrations nos testes | Testa mudanças de schema |
| Use fixtures para dados complexos | Testes consistentes e legíveis |
Executando os Testes
Para rodar todos os testes de integração:
# Roda todos os testes
cargo test
# Roda testes específicos
cargo test test_create_user
# Roda com saída detalhada
cargo test -- --nocapture
# Roda testes em paralelo (padrão)
cargo test -- --test-threads=4
Dicas de Performance
-
Reutilize o cliente Docker: Use
OnceLockoulazy_staticpara singleton -
Minimize migrations: Execute apenas as migrations necessárias
-
Use transações para cleanup: Mais rápido que truncar tabelas
-
Cache imagens Docker: CI/CD deve cachear imagens para startup mais rápido
-
Paralelização inteligente: Deixe cada teste ter seu próprio container
Troubleshooting
Erro: "Cannot connect to Docker daemon"
# Verifique se Docker está rodando
docker ps
# No Linux, adicione seu usuário ao grupo docker
sudo usermod -aG docker $USER
Erro: "Port already in use"
Testcontainers usa portas aleatórias automaticamente. Se ver este erro, pode ser um container antigo:
# Limpa containers antigos
docker ps -a | grep testcontainers | awk '{print $1}' | xargs docker rm -f
Testes lentos
Se seus testes estão lentos, considere:
-
Reutilizar containers entre testes relacionados
-
Usar transações em vez de truncar tabelas
-
Cachear imagens Docker localmente
-
Desabilitar fsync no PostgreSQL para testes
Conclusão
Testcontainers transforma testes de integração em Rust de uma tarefa complexa em algo simples e confiável. Usando containers Docker reais, você elimina a necessidade de mocks complexos e captura problemas que só aparecem em ambientes reais.
A configuração inicial pode parecer trabalhosa, mas o retorno em confiança e manutenibilidade do código vale a pena. Seus testes se tornam documentação viva de como sua aplicação interage com serviços externos.
Comece implementando testes para suas operações de banco de dados mais críticas e, gradualmente, expanda para cobrir toda sua API. Com o tempo, você terá uma suite de testes robusta que te dá confiança para refatorar e adicionar novas features.
Recursos adicionais: