No Artigo #16 cobrimos os fundamentos de testes em Rust — testes unitários, de integração, e as macros de asserção. Esses fundamentos são suficientes para a maioria dos projetos, mas há uma categoria inteira de técnicas que elevam a qualidade dos testes a outro nível: property-based testing, mocks e benchmarks.
Cada uma resolve um problema diferente. Property-based testing encontra casos que você nunca pensaria em testar. Mocks isolam o código que você quer testar de suas dependências. Benchmarks medem se seu código é rápido o suficiente — e se ficou mais lento após uma mudança.
Property-Based Testing com proptest
Testes baseados em exemplos — como os que escrevemos até agora — verificam casos específicos: "dado entrada X, espero saída Y". O problema é que você só testa os casos que imaginou. Bugs vivem nos casos que você não imaginou.
Property-based testing inverte a abordagem: em vez de escrever exemplos, você descreve propriedades que devem ser verdadeiras para qualquer entrada válida. A biblioteca gera centenas de entradas aleatórias e verifica se a propriedade se mantém.
[dev-dependencies]
proptest = "1"
Conceito fundamental
use proptest::prelude::*;
fn ordenar(mut v: Vec<i32>) -> Vec<i32> {
v.sort();
v
}
proptest! {
// Esta propriedade deve ser verdadeira para QUALQUER Vec<i32>
#[test]
fn ordenar_mantem_tamanho(v: Vec<i32>) {
let ordenado = ordenar(v.clone());
// Propriedade 1: tamanho não muda
prop_assert_eq!(ordenado.len(), v.len());
}
#[test]
fn ordenar_e_idempotente(v: Vec<i32>) {
let uma_vez = ordenar(v.clone());
let duas_vezes = ordenar(uma_vez.clone());
// Propriedade 2: ordenar duas vezes = ordenar uma vez
prop_assert_eq!(uma_vez, duas_vezes);
}
#[test]
fn ordenar_produz_sequencia_nao_decrescente(v: Vec<i32>) {
let ordenado = ordenar(v);
// Propriedade 3: cada elemento ≤ próximo
for janela in ordenado.windows(2) {
prop_assert!(janela[0] <= janela[1]);
}
}
#[test]
fn ordenar_contem_mesmos_elementos(v: Vec<i32>) {
let mut original = v.clone();
let mut ordenado = ordenar(v);
// Propriedade 4: mesmos elementos (comparação após ordenar ambos)
original.sort();
prop_assert_eq!(original, ordenado);
}
}
Execute com cargo test — proptest gera 256 casos por padrão para cada propriedade.
Quando proptest encontra uma falha
A grande qualidade do proptest é o shrinking automático. Quando encontra uma entrada que falha, ele a reduz ao menor exemplo possível:
use proptest::prelude::*;
// Função com bug intencional: falha para números > 1000
fn processar(n: i32) -> i32 {
if n > 1000 {
panic!("Não esperava número tão grande!");
}
n * 2
}
proptest! {
#[test]
fn processar_sempre_dobra(n: i32) {
// Esta propriedade vai falhar
prop_assert_eq!(processar(n), n * 2);
}
}
Saída:
FAILED. Shrinking...
Shrunk to n = 1001
thread 'processar_sempre_dobra' panicked at 'Não esperava número tão grande!'
Em vez de reportar o número aleatório original (ex: 847392), proptest reduz ao menor valor que reproduz o bug: 1001.
Estratégias customizadas
Proptest tem estratégias para gerar tipos complexos:
use proptest::prelude::*;
use proptest::collection::vec;
#[derive(Debug, Clone)]
struct Produto {
nome: String,
preco: f64,
quantidade: u32,
}
fn estrategia_produto() -> impl Strategy<Value = Produto> {
(
"[a-zA-Z]{3,20}", // nome: string com 3-20 letras
0.01f64..10000.0f64, // preco: entre 0.01 e 10000
0u32..1000u32, // quantidade: entre 0 e 999
)
.prop_map(|(nome, preco, quantidade)| Produto {
nome,
preco,
quantidade,
})
}
fn calcular_total(produtos: &[Produto]) -> f64 {
produtos.iter().map(|p| p.preco * p.quantidade as f64).sum()
}
proptest! {
#[test]
fn total_nunca_e_negativo(
produtos in vec(estrategia_produto(), 0..20)
) {
let total = calcular_total(&produtos);
prop_assert!(total >= 0.0, "Total negativo: {total}");
}
#[test]
fn total_com_lista_vazia_e_zero(
_dummy in Just(())
) {
prop_assert_eq!(calcular_total(&[]), 0.0);
}
#[test]
fn adicionar_produto_aumenta_ou_mantem_total(
mut produtos in vec(estrategia_produto(), 1..10),
novo in estrategia_produto(),
) {
let total_antes = calcular_total(&produtos);
produtos.push(novo);
let total_depois = calcular_total(&produtos);
prop_assert!(total_depois >= total_antes);
}
}
Aplicando proptest na API de tarefas
use proptest::prelude::*;
// Testa propriedades do modelo Tarefa
proptest! {
#[test]
fn titulo_sem_espacos_extremos_apos_trim(
espacos_antes in " {0,10}",
conteudo in "[a-zA-ZÀ-ÿ ]{1,100}",
espacos_depois in " {0,10}",
) {
let titulo_bruto = format!("{espacos_antes}{conteudo}{espacos_depois}");
let titulo_limpo = titulo_bruto.trim().to_string();
// Propriedade: após trim, não começa nem termina com espaço
prop_assert!(!titulo_limpo.starts_with(' '));
prop_assert!(!titulo_limpo.ends_with(' '));
// Propriedade: o conteúdo original está preservado
prop_assert!(titulo_limpo.contains(conteudo.trim()));
}
#[test]
fn paginacao_nunca_retorna_mais_que_solicitado(
total_itens in 0usize..200usize,
por_pagina in 1usize..50usize,
pagina in 1usize..20usize,
) {
// Simula paginação
let itens: Vec<i32> = (0..total_itens as i32).collect();
let inicio = (pagina - 1) * por_pagina;
let pagina_resultado: Vec<&i32> = itens
.iter()
.skip(inicio)
.take(por_pagina)
.collect();
// Propriedade: página nunca tem mais itens que por_pagina
prop_assert!(pagina_resultado.len() <= por_pagina);
}
}
Mocks com mockall
Mocks permitem testar código que depende de serviços externos — banco de dados, APIs HTTP, sistemas de email — sem realmente usar esses serviços. Você substitui a dependência real por uma versão simulada que você controla.
[dev-dependencies]
mockall = "0.12"
Definindo traits mockáveis
O segredo dos mocks em Rust é definir dependências como traits. O código de produção usa a implementação real; os testes usam o mock:
use mockall::{automock, predicate::*};
use uuid::Uuid;
use crate::modelo::Tarefa;
use crate::erro::ResultadoApi;
// A anotação #[automock] gera automaticamente MockRepositorioTarefas
#[automock]
pub trait RepositorioTarefasTrait: Send + Sync {
async fn criar(&self, tarefa: &Tarefa) -> ResultadoApi<()>;
async fn buscar_por_id(&self, id: &Uuid) -> ResultadoApi<Tarefa>;
async fn deletar(&self, id: &Uuid) -> ResultadoApi<()>;
}
#[automock]
pub trait ServicoEmailTrait: Send + Sync {
async fn enviar_confirmacao(&self, email: &str, nome: &str) -> ResultadoApi<()>;
async fn enviar_notificacao(&self, email: &str, mensagem: &str) -> ResultadoApi<()>;
}
Serviço usando os traits
use std::sync::Arc;
pub struct ServicoCriarTarefa<R, E>
where
R: RepositorioTarefasTrait,
E: ServicoEmailTrait,
{
repo: Arc<R>,
email: Arc<E>,
}
impl<R: RepositorioTarefasTrait, E: ServicoEmailTrait> ServicoCriarTarefa<R, E> {
pub fn novo(repo: Arc<R>, email: Arc<E>) -> Self {
ServicoCriarTarefa { repo, email }
}
pub async fn executar(
&self,
titulo: String,
email_usuario: &str,
nome_usuario: &str,
) -> ResultadoApi<Tarefa> {
use crate::modelo::Prioridade;
if titulo.trim().is_empty() {
return Err(crate::erro::ErroApi::TituloVazio);
}
let tarefa = Tarefa::nova(titulo.trim().to_string(), None, Prioridade::Media);
self.repo.criar(&tarefa).await?;
// Envia notificação por email
self.email
.enviar_notificacao(
email_usuario,
&format!("Tarefa '{}' criada com sucesso!", tarefa.titulo),
)
.await?;
Ok(tarefa)
}
}
Testes com mocks
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
use std::sync::Arc;
#[tokio::test]
async fn criar_tarefa_chama_repo_e_email() {
let mut mock_repo = MockRepositorioTarefasTrait::new();
let mut mock_email = MockServicoEmailTrait::new();
// Configura expectativa: criar() deve ser chamado uma vez
mock_repo
.expect_criar()
.times(1)
.returning(|_| Box::pin(async { Ok(()) }));
// Configura expectativa: enviar_notificacao() com email específico
mock_email
.expect_enviar_notificacao()
.with(eq("ana@exemplo.com"), always())
.times(1)
.returning(|_, _| Box::pin(async { Ok(()) }));
let servico = ServicoCriarTarefa::novo(
Arc::new(mock_repo),
Arc::new(mock_email),
);
let resultado = servico
.executar(
"Minha tarefa".to_string(),
"ana@exemplo.com",
"Ana",
)
.await;
assert!(resultado.is_ok());
assert_eq!(resultado.unwrap().titulo, "Minha tarefa");
}
#[tokio::test]
async fn titulo_vazio_nao_chama_repo() {
let mut mock_repo = MockRepositorioTarefasTrait::new();
let mut mock_email = MockServicoEmailTrait::new();
// Configura expectativa: criar() NÃO deve ser chamado
mock_repo.expect_criar().times(0);
mock_email.expect_enviar_notificacao().times(0);
let servico = ServicoCriarTarefa::novo(
Arc::new(mock_repo),
Arc::new(mock_email),
);
let resultado = servico
.executar(" ".to_string(), "ana@exemplo.com", "Ana")
.await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn falha_no_repo_nao_envia_email() {
use crate::erro::ErroApi;
let mut mock_repo = MockRepositorioTarefasTrait::new();
let mut mock_email = MockServicoEmailTrait::new();
// Repo falha
mock_repo
.expect_criar()
.times(1)
.returning(|_| Box::pin(async { Err(ErroApi::ErroInterno) }));
// Email NÃO deve ser enviado se repo falhar
mock_email.expect_enviar_notificacao().times(0);
let servico = ServicoCriarTarefa::novo(
Arc::new(mock_repo),
Arc::new(mock_email),
);
let resultado = servico
.executar("Tarefa".to_string(), "ana@exemplo.com", "Ana")
.await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn mock_retorna_tarefa_especifica() {
use crate::modelo::Prioridade;
let tarefa_esperada = Tarefa::nova(
"Tarefa específica".to_string(),
None,
Prioridade::Alta,
);
let id = tarefa_esperada.id;
let clone = tarefa_esperada.clone();
let mut mock_repo = MockRepositorioTarefasTrait::new();
mock_repo
.expect_buscar_por_id()
.with(eq(id))
.times(1)
.return_once(move |_| {
Box::pin(async move { Ok(clone) })
});
let resultado = mock_repo.buscar_por_id(&id).await.unwrap();
assert_eq!(resultado.titulo, "Tarefa específica");
}
}
Mock de cliente HTTP
Um caso muito comum é mockar chamadas HTTP externas:
#[automock]
pub trait ClienteHttpTrait: Send + Sync {
async fn get_json(&self, url: &str) -> ResultadoApi<serde_json::Value>;
async fn post_json(
&self,
url: &str,
corpo: serde_json::Value,
) -> ResultadoApi<serde_json::Value>;
}
pub struct ServicoPrevisao<C: ClienteHttpTrait> {
cliente: Arc<C>,
url_base: String,
}
impl<C: ClienteHttpTrait> ServicoPrevisao<C> {
pub async fn buscar_temperatura(&self, cidade: &str) -> ResultadoApi<f64> {
let url = format!("{}/tempo/{cidade}", self.url_base);
let resposta = self.cliente.get_json(&url).await?;
resposta["temperatura"]
.as_f64()
.ok_or(crate::erro::ErroApi::DadosInvalidos(
"Campo 'temperatura' ausente".to_string(),
))
}
}
#[cfg(test)]
mod tests_http {
use super::*;
use serde_json::json;
#[tokio::test]
async fn buscar_temperatura_parseia_resposta() {
let mut mock = MockClienteHttpTrait::new();
mock.expect_get_json()
.with(contains("Curitiba"))
.times(1)
.returning(|_| {
Box::pin(async {
Ok(json!({ "temperatura": 18.5, "umidade": 72 }))
})
});
let servico = ServicoPrevisao {
cliente: Arc::new(mock),
url_base: "https://api.tempo.exemplo".to_string(),
};
let temp = servico.buscar_temperatura("Curitiba").await.unwrap();
assert!((temp - 18.5).abs() < 0.001);
}
#[tokio::test]
async fn buscar_temperatura_propaga_erro_http() {
let mut mock = MockClienteHttpTrait::new();
mock.expect_get_json()
.times(1)
.returning(|_| {
Box::pin(async {
Err(crate::erro::ErroApi::ErroInterno)
})
});
let servico = ServicoPrevisao {
cliente: Arc::new(mock),
url_base: "https://api.tempo.exemplo".to_string(),
};
assert!(servico.buscar_temperatura("Curitiba").await.is_err());
}
}
Benchmarks com criterion
Benchmarks medem performance de forma rigorosa — com análise estatística, múltiplas amostras e detecção de regressões. A crate criterion é o padrão para benchmarks em Rust.
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "benchmarks"
harness = false
benches/benchmarks.rs:
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::collections::HashMap;
// Funções a serem comparadas
fn busca_linear(lista: &[i32], alvo: i32) -> Option<usize> {
lista.iter().position(|&x| x == alvo)
}
fn busca_binaria(lista: &[i32], alvo: i32) -> Option<usize> {
lista.binary_search(&alvo).ok()
}
fn contar_palavras_loop(texto: &str) -> HashMap<&str, usize> {
let mut contagem = HashMap::new();
for palavra in texto.split_whitespace() {
*contagem.entry(palavra).or_insert(0) += 1;
}
contagem
}
fn contar_palavras_fold(texto: &str) -> HashMap<&str, usize> {
texto.split_whitespace().fold(HashMap::new(), |mut acc, palavra| {
*acc.entry(palavra).or_insert(0) += 1;
acc
})
}
fn bench_busca(c: &mut Criterion) {
let mut grupo = c.benchmark_group("Busca");
for tamanho in [100, 1_000, 10_000, 100_000] {
let lista: Vec<i32> = (0..tamanho).collect();
let alvo = tamanho / 2; // elemento no meio
grupo.throughput(Throughput::Elements(tamanho as u64));
grupo.bench_with_input(
BenchmarkId::new("linear", tamanho),
&(&lista, alvo),
|b, (lista, alvo)| {
b.iter(|| busca_linear(black_box(lista), black_box(*alvo)))
},
);
grupo.bench_with_input(
BenchmarkId::new("binaria", tamanho),
&(&lista, alvo),
|b, (lista, alvo)| {
b.iter(|| busca_binaria(black_box(lista), black_box(*alvo)))
},
);
}
grupo.finish();
}
fn bench_contagem_palavras(c: &mut Criterion) {
let textos = [
("pequeno", "o rato roeu a roupa do rei de roma"),
("medio", &"palavra ".repeat(100)),
("grande", &"rust é incrível ".repeat(1000)),
];
let mut grupo = c.benchmark_group("ContarPalavras");
for (nome, texto) in &textos {
grupo.bench_with_input(
BenchmarkId::new("loop", nome),
texto,
|b, t| b.iter(|| contar_palavras_loop(black_box(t))),
);
grupo.bench_with_input(
BenchmarkId::new("fold", nome),
texto,
|b, t| b.iter(|| contar_palavras_fold(black_box(t))),
);
}
grupo.finish();
}
fn bench_alocacao(c: &mut Criterion) {
let mut grupo = c.benchmark_group("Alocação");
grupo.bench_function("vec_push_sem_capacidade", |b| {
b.iter(|| {
let mut v = Vec::new();
for i in 0..1000 {
v.push(black_box(i));
}
v
})
});
grupo.bench_function("vec_push_com_capacidade", |b| {
b.iter(|| {
let mut v = Vec::with_capacity(1000);
for i in 0..1000 {
v.push(black_box(i));
}
v
})
});
grupo.bench_function("collect_de_range", |b| {
b.iter(|| {
let v: Vec<i32> = (0..1000).collect();
black_box(v)
})
});
grupo.finish();
}
criterion_group!(benches, bench_busca, bench_contagem_palavras, bench_alocacao);
criterion_main!(benches);
Execute com:
cargo bench
Saída típica:
Busca/linear/100 time: [245.32 ns 246.18 ns 247.12 ns]
Busca/binaria/100 time: [12.45 ns 12.51 ns 12.58 ns]
Busca/linear/100000 time: [245.32 µs 246.18 µs 247.12 µs]
Busca/binaria/100000 time: [18.23 ns 18.31 ns 18.40 ns]
ContarPalavras/loop/pequeno time: [312 ns 315 ns 318 ns]
ContarPalavras/fold/pequeno time: [308 ns 311 ns 314 ns]
Alocação/vec_push_sem_capacidade time: [8.45 µs 8.52 µs 8.60 µs]
Alocação/vec_push_com_capacidade time: [2.31 µs 2.33 µs 2.35 µs]
Alocação/collect_de_range time: [1.89 µs 1.91 µs 1.93 µs]
Criterion gera relatórios HTML em target/criterion/ com gráficos de distribuição e histórico de performance.
Detectando regressões
Para detectar regressões de performance automaticamente, salve uma baseline:
# Salvar baseline atual
cargo bench -- --save-baseline main
# Após mudanças no código, comparar com baseline
cargo bench -- --baseline main
Se o código ficou mais lento, criterion reporta:
Busca/linear/1000 Performance has regressed.
change: [+23.5% +24.8% +26.1%] (p = 0.00 < 0.05)
Time has increased significantly.
Um programa completo: suíte de testes integrada
Vamos combinar as três técnicas para testar um módulo de processamento de dados:
pub mod processador {
#[derive(Debug, Clone, PartialEq)]
pub struct Estatisticas {
pub min: f64,
pub max: f64,
pub media: f64,
pub mediana: f64,
pub desvio_padrao: f64,
}
pub fn calcular(dados: &[f64]) -> Option<Estatisticas> {
if dados.is_empty() {
return None;
}
let n = dados.len() as f64;
let min = dados.iter().cloned().fold(f64::INFINITY, f64::min);
let max = dados.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let soma: f64 = dados.iter().sum();
let media = soma / n;
let mut ordenado = dados.to_vec();
ordenado.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mediana = if dados.len() % 2 == 0 {
(ordenado[dados.len() / 2 - 1] + ordenado[dados.len() / 2]) / 2.0
} else {
ordenado[dados.len() / 2]
};
let variancia = dados.iter()
.map(|&x| (x - media).powi(2))
.sum::<f64>() / n;
let desvio_padrao = variancia.sqrt();
Some(Estatisticas { min, max, media, mediana, desvio_padrao })
}
pub fn normalizar(dados: &[f64]) -> Option<Vec<f64>> {
let stats = calcular(dados)?;
let amplitude = stats.max - stats.min;
if amplitude == 0.0 {
return Some(vec![0.5; dados.len()]);
}
Some(dados.iter()
.map(|&x| (x - stats.min) / amplitude)
.collect())
}
}
#[cfg(test)]
mod testes_unitarios {
use super::processador::*;
#[test]
fn lista_vazia_retorna_none() {
assert!(calcular(&[]).is_none());
assert!(normalizar(&[]).is_none());
}
#[test]
fn elemento_unico() {
let stats = calcular(&[42.0]).unwrap();
assert_eq!(stats.min, 42.0);
assert_eq!(stats.max, 42.0);
assert_eq!(stats.media, 42.0);
assert_eq!(stats.mediana, 42.0);
assert_eq!(stats.desvio_padrao, 0.0);
}
#[test]
fn mediana_lista_par() {
let stats = calcular(&[1.0, 2.0, 3.0, 4.0]).unwrap();
assert_eq!(stats.mediana, 2.5);
}
#[test]
fn normalizar_produz_valores_entre_0_e_1() {
let dados = vec![10.0, 20.0, 30.0, 40.0, 50.0];
let norm = normalizar(&dados).unwrap();
assert_eq!(norm[0], 0.0);
assert_eq!(norm[4], 1.0);
for &v in &norm {
assert!(v >= 0.0 && v <= 1.0);
}
}
}
#[cfg(test)]
mod testes_propriedades {
use super::processador::*;
use proptest::prelude::*;
proptest! {
#[test]
fn min_menor_ou_igual_a_max(dados in prop::collection::vec(-1000.0f64..1000.0f64, 1..50)) {
let stats = calcular(&dados).unwrap();
prop_assert!(stats.min <= stats.max);
}
#[test]
fn media_entre_min_e_max(dados in prop::collection::vec(-1000.0f64..1000.0f64, 1..50)) {
let stats = calcular(&dados).unwrap();
prop_assert!(stats.media >= stats.min - 1e-10);
prop_assert!(stats.media <= stats.max + 1e-10);
}
#[test]
fn desvio_padrao_nunca_negativo(dados in prop::collection::vec(-1000.0f64..1000.0f64, 1..50)) {
let stats = calcular(&dados).unwrap();
prop_assert!(stats.desvio_padrao >= 0.0);
}
#[test]
fn normalizados_entre_0_e_1(dados in prop::collection::vec(-1000.0f64..1000.0f64, 2..50)) {
if let Some(norm) = normalizar(&dados) {
for &v in &norm {
prop_assert!(v >= -1e-10 && v <= 1.0 + 1e-10,
"Valor fora do intervalo [0,1]: {v}");
}
}
}
#[test]
fn tamanho_preservado_na_normalizacao(dados in prop::collection::vec(-1000.0f64..1000.0f64, 1..50)) {
if let Some(norm) = normalizar(&dados) {
prop_assert_eq!(norm.len(), dados.len());
}
}
}
}
Quando usar cada técnica
Com três ferramentas poderosas, a questão é saber quando usar cada uma:
Testes baseados em exemplos são o ponto de partida. Use para comportamentos específicos, casos de erro conhecidos, e documentação de como o código deve se comportar. São fáceis de escrever e de ler.
Property-based testing complementa testes de exemplo para código com domínio amplo — funções matemáticas, parsers, algoritmos de ordenação, serializadores. Use quando a propriedade é mais fácil de descrever do que enumerar exemplos. Especialmente valioso para encontrar bugs em casos extremos.
Mocks são essenciais para código com efeitos colaterais — I/O, rede, banco de dados, tempo. Use para isolar o código que você quer testar de suas dependências externas. Evite mocks para dependências internas — prefira usar as implementações reais.
Benchmarks pertencem a um momento específico: quando você precisa provar que o código é rápido o suficiente, ou verificar que uma otimização realmente melhorou a performance. Não escreva benchmarks prematuramente — primeiro faça funcionar, depois meça, depois otimize.
Fontes e leituras recomendadas
proptestdocumentation — https://docs.rs/proptestproptestBook — guia completo — https://proptest-rs.github.io/proptest/mockalldocumentation — https://docs.rs/mockallcriteriondocumentation — https://docs.rs/criterion- "Criterion.rs User Guide" — https://bheisler.github.io/criterion.rs/book/
- "Property-Based Testing with PropTest" — blog.logrocket.com — introdução prática — https://blog.logrocket.com/property-based-testing-in-rust-with-proptest/
- "Testing" — Rust by Example — https://doc.rust-lang.org/rust-by-example/testing.html
- "Zero to Production in Rust" — Luca Palmieri — cap. sobre testes de integração — https://www.zero2prod.com