Introdução
Testes são cidadãos de primeira classe em Rust. A linguagem possui suporte nativo para testes unitários, testes de integração e doc tests, sem necessidade de frameworks externos para o básico. Neste tutorial, vamos explorar todas as formas de testar código Rust, desde testes simples até mocking e benchmarks.
Testes Unitários com #[test]
Testes unitários em Rust são escritos no mesmo arquivo do código que testam, dentro de um módulo anotado com #[cfg(test)]:
// src/lib.rs
pub fn somar(a: i32, b: i32) -> i32 {
a + b
}
pub fn dividir(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Divisão por zero".to_string())
} else {
Ok(a / b)
}
}
pub fn eh_palindromo(texto: &str) -> bool {
let limpo: String = texto
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect();
let reverso: String = limpo.chars().rev().collect();
limpo == reverso
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_somar_positivos() {
assert_eq!(somar(2, 3), 5);
}
#[test]
fn test_somar_negativos() {
assert_eq!(somar(-2, -3), -5);
}
#[test]
fn test_somar_zero() {
assert_eq!(somar(0, 0), 0);
}
#[test]
fn test_dividir_sucesso() {
let resultado = dividir(10.0, 3.0).unwrap();
assert!((resultado - 3.333).abs() < 0.01);
}
#[test]
fn test_dividir_por_zero() {
let resultado = dividir(10.0, 0.0);
assert!(resultado.is_err());
assert_eq!(resultado.unwrap_err(), "Divisão por zero");
}
#[test]
fn test_palindromo_verdadeiro() {
assert!(eh_palindromo("Ana"));
assert!(eh_palindromo("Socorram-me, subi no ônibus em Marrocos"));
}
#[test]
fn test_palindromo_falso() {
assert!(!eh_palindromo("Rust"));
assert!(!eh_palindromo("Brasil"));
}
}
Executando Testes
# Executar todos os testes
cargo test
# Executar testes com saída detalhada
cargo test -- --show-output
# Filtrar testes por nome
cargo test palindromo
# Executar um teste específico
cargo test test_dividir_sucesso
# Executar testes em uma única thread
cargo test -- --test-threads=1
Macros de Asserção
O Rust oferece várias macros para asserções em testes:
#[cfg(test)]
mod tests {
#[test]
fn demonstrar_assertions() {
// Igualdade
assert_eq!(2 + 2, 4);
// Desigualdade
assert_ne!("Rust", "Go");
// Booleano
assert!(true);
// Com mensagem personalizada
let valor = 42;
assert_eq!(
valor, 42,
"O valor deveria ser 42, mas era {}",
valor
);
// Comparações de floats com tolerância
let resultado = 0.1 + 0.2;
assert!(
(resultado - 0.3).abs() < f64::EPSILON * 4.0,
"Erro de ponto flutuante: {} != 0.3",
resultado
);
}
// Testando se o código entra em pânico
#[test]
#[should_panic]
fn test_panic_generico() {
panic!("Este teste deve entrar em pânico");
}
// Testando pânico com mensagem específica
#[test]
#[should_panic(expected = "índice fora")]
fn test_panic_especifico() {
let v = vec![1, 2, 3];
let _ = v[10]; // Pânico: índice fora dos limites
}
// Testes que retornam Result
#[test]
fn test_com_result() -> Result<(), String> {
let resultado = "42".parse::<i32>().map_err(|e| e.to_string())?;
if resultado == 42 {
Ok(())
} else {
Err(format!("Esperado 42, obteve {}", resultado))
}
}
// Ignorar um teste (útil para testes lentos)
#[test]
#[ignore]
fn test_lento() {
std::thread::sleep(std::time::Duration::from_secs(10));
assert!(true);
}
}
Para executar testes ignorados:
# Executar apenas testes ignorados
cargo test -- --ignored
# Executar todos os testes, incluindo ignorados
cargo test -- --include-ignored
Testes de Integração
Testes de integração ficam no diretório tests/ na raiz do projeto. Cada arquivo é compilado como um crate separado:
meu_projeto/
├── src/
│ └── lib.rs
├── tests/
│ ├── test_calculadora.rs
│ ├── test_palindromo.rs
│ └── common/
│ └── mod.rs
└── Cargo.toml
Exemplo de Teste de Integração
// tests/test_calculadora.rs
use meu_projeto::{dividir, somar};
mod common;
#[test]
fn test_operacoes_encadeadas() {
let resultado = somar(10, 20);
let final_val = dividir(resultado as f64, 5.0).unwrap();
assert_eq!(final_val, 6.0);
}
#[test]
fn test_cenario_completo() {
// Usar helpers compartilhados
let dados = common::criar_dados_teste();
assert!(!dados.is_empty());
}
Código Compartilhado entre Testes
// tests/common/mod.rs
pub fn criar_dados_teste() -> Vec<i32> {
vec![1, 2, 3, 4, 5]
}
pub fn setup() {
// Configuração comum para testes
println!("Configurando ambiente de teste...");
}
Doc Tests
Os doc tests são exemplos de código na documentação que são executados como testes:
/// Calcula a média de uma lista de números.
///
/// # Exemplos
///
/// ```
/// use meu_projeto::media;
///
/// let numeros = vec![10.0, 20.0, 30.0];
/// let resultado = media(&numeros).unwrap();
/// assert_eq!(resultado, 20.0);
/// ```
///
/// Retorna `None` para listas vazias:
///
/// ```
/// use meu_projeto::media;
///
/// let vazia: Vec<f64> = vec![];
/// assert_eq!(media(&vazia), None);
/// ```
pub fn media(numeros: &[f64]) -> Option<f64> {
if numeros.is_empty() {
None
} else {
Some(numeros.iter().sum::<f64>() / numeros.len() as f64)
}
}
/// Converte temperatura de Celsius para Fahrenheit.
///
/// # Exemplos
///
/// ```
/// use meu_projeto::celsius_para_fahrenheit;
///
/// assert_eq!(celsius_para_fahrenheit(0.0), 32.0);
/// assert_eq!(celsius_para_fahrenheit(100.0), 212.0);
/// ```
///
/// # Pânico
///
/// Não entra em pânico para valores negativos:
///
/// ```
/// use meu_projeto::celsius_para_fahrenheit;
///
/// let resultado = celsius_para_fahrenheit(-40.0);
/// assert_eq!(resultado, -40.0); // -40 é o mesmo em ambas as escalas!
/// ```
pub fn celsius_para_fahrenheit(celsius: f64) -> f64 {
celsius * 9.0 / 5.0 + 32.0
}
Organização de Testes
Uma boa organização de testes segue este padrão:
// src/usuario.rs
#[derive(Debug, Clone, PartialEq)]
pub struct Usuario {
pub nome: String,
pub email: String,
pub idade: u8,
}
impl Usuario {
pub fn new(nome: &str, email: &str, idade: u8) -> Result<Self, Vec<String>> {
let mut erros = Vec::new();
if nome.trim().is_empty() {
erros.push("Nome não pode ser vazio".to_string());
}
if !email.contains('@') {
erros.push("Email inválido".to_string());
}
if idade < 13 {
erros.push("Idade mínima é 13 anos".to_string());
}
if erros.is_empty() {
Ok(Usuario {
nome: nome.trim().to_string(),
email: email.trim().to_string(),
idade,
})
} else {
Err(erros)
}
}
pub fn nome_formatado(&self) -> String {
self.nome
.split_whitespace()
.map(|p| {
let mut chars = p.chars();
match chars.next() {
None => String::new(),
Some(c) => {
c.to_uppercase().to_string() + &chars.as_str().to_lowercase()
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
#[cfg(test)]
mod tests {
use super::*;
// Agrupar testes por funcionalidade
mod criacao {
use super::*;
#[test]
fn usuario_valido() {
let usuario = Usuario::new("Maria Silva", "maria@email.com", 25);
assert!(usuario.is_ok());
let u = usuario.unwrap();
assert_eq!(u.nome, "Maria Silva");
}
#[test]
fn nome_vazio_retorna_erro() {
let resultado = Usuario::new("", "email@test.com", 20);
assert!(resultado.is_err());
let erros = resultado.unwrap_err();
assert!(erros.iter().any(|e| e.contains("Nome")));
}
#[test]
fn email_invalido_retorna_erro() {
let resultado = Usuario::new("João", "email-invalido", 20);
assert!(resultado.is_err());
}
#[test]
fn idade_minima() {
let resultado = Usuario::new("Pedro", "pedro@email.com", 12);
assert!(resultado.is_err());
let erros = resultado.unwrap_err();
assert!(erros.iter().any(|e| e.contains("13")));
}
#[test]
fn multiplos_erros() {
let resultado = Usuario::new("", "invalido", 5);
assert!(resultado.is_err());
let erros = resultado.unwrap_err();
assert_eq!(erros.len(), 3);
}
}
mod formatacao {
use super::*;
#[test]
fn nome_formatado_capitalizado() {
let u = Usuario::new("maria silva", "m@e.com", 20).unwrap();
assert_eq!(u.nome_formatado(), "Maria Silva");
}
#[test]
fn nome_formatado_tudo_maiusculo() {
let u = Usuario::new("JOÃO PEDRO", "j@e.com", 20).unwrap();
assert_eq!(u.nome_formatado(), "João Pedro");
}
}
}
Mocking com Mockall
Para testar código que depende de serviços externos, usamos mocks. A crate mockall facilita a criação de mocks:
[dev-dependencies]
mockall = "0.13"
use mockall::automock;
#[automock]
trait RepositorioUsuario {
fn buscar_por_id(&self, id: u64) -> Option<Usuario>;
fn salvar(&self, usuario: &Usuario) -> Result<u64, String>;
fn listar_todos(&self) -> Vec<Usuario>;
}
#[derive(Debug, Clone, PartialEq)]
struct Usuario {
id: Option<u64>,
nome: String,
email: String,
}
struct ServicoUsuario<R: RepositorioUsuario> {
repositorio: R,
}
impl<R: RepositorioUsuario> ServicoUsuario<R> {
fn new(repositorio: R) -> Self {
ServicoUsuario { repositorio }
}
fn obter_usuario(&self, id: u64) -> Result<Usuario, String> {
self.repositorio
.buscar_por_id(id)
.ok_or_else(|| format!("Usuário {} não encontrado", id))
}
fn criar_usuario(&self, nome: &str, email: &str) -> Result<u64, String> {
if nome.is_empty() {
return Err("Nome é obrigatório".to_string());
}
let usuario = Usuario {
id: None,
nome: nome.to_string(),
email: email.to_string(),
};
self.repositorio.salvar(&usuario)
}
fn total_usuarios(&self) -> usize {
self.repositorio.listar_todos().len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
#[test]
fn test_obter_usuario_existente() {
let mut mock_repo = MockRepositorioUsuario::new();
mock_repo
.expect_buscar_por_id()
.with(eq(1))
.times(1)
.returning(|_| {
Some(Usuario {
id: Some(1),
nome: "Maria".to_string(),
email: "maria@test.com".to_string(),
})
});
let servico = ServicoUsuario::new(mock_repo);
let resultado = servico.obter_usuario(1);
assert!(resultado.is_ok());
assert_eq!(resultado.unwrap().nome, "Maria");
}
#[test]
fn test_obter_usuario_inexistente() {
let mut mock_repo = MockRepositorioUsuario::new();
mock_repo
.expect_buscar_por_id()
.with(eq(999))
.times(1)
.returning(|_| None);
let servico = ServicoUsuario::new(mock_repo);
let resultado = servico.obter_usuario(999);
assert!(resultado.is_err());
assert!(resultado.unwrap_err().contains("não encontrado"));
}
#[test]
fn test_criar_usuario_sucesso() {
let mut mock_repo = MockRepositorioUsuario::new();
mock_repo
.expect_salvar()
.times(1)
.returning(|_| Ok(42));
let servico = ServicoUsuario::new(mock_repo);
let resultado = servico.criar_usuario("João", "joao@test.com");
assert_eq!(resultado, Ok(42));
}
#[test]
fn test_criar_usuario_nome_vazio() {
let mock_repo = MockRepositorioUsuario::new();
// O mock não espera nenhuma chamada a salvar
let servico = ServicoUsuario::new(mock_repo);
let resultado = servico.criar_usuario("", "test@test.com");
assert!(resultado.is_err());
}
#[test]
fn test_total_usuarios() {
let mut mock_repo = MockRepositorioUsuario::new();
mock_repo.expect_listar_todos().times(1).returning(|| {
vec![
Usuario { id: Some(1), nome: "A".into(), email: "a@t.com".into() },
Usuario { id: Some(2), nome: "B".into(), email: "b@t.com".into() },
Usuario { id: Some(3), nome: "C".into(), email: "c@t.com".into() },
]
});
let servico = ServicoUsuario::new(mock_repo);
assert_eq!(servico.total_usuarios(), 3);
}
}
Benchmarks com Criterion
Para benchmarks precisos, usamos a crate criterion:
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "meus_benchmarks"
harness = false
Crie o arquivo de benchmark:
// benches/meus_benchmarks.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn fibonacci_recursivo(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
_ => fibonacci_recursivo(n - 1) + fibonacci_recursivo(n - 2),
}
}
fn fibonacci_iterativo(n: u64) -> u64 {
if n <= 1 {
return n;
}
let mut a: u64 = 0;
let mut b: u64 = 1;
for _ in 2..=n {
let temp = b;
b = a + b;
a = temp;
}
b
}
fn benchmark_fibonacci(c: &mut Criterion) {
let mut grupo = c.benchmark_group("Fibonacci");
for n in [10, 20, 30].iter() {
grupo.bench_with_input(
BenchmarkId::new("Recursivo", n),
n,
|b, &n| b.iter(|| fibonacci_recursivo(black_box(n))),
);
grupo.bench_with_input(
BenchmarkId::new("Iterativo", n),
n,
|b, &n| b.iter(|| fibonacci_iterativo(black_box(n))),
);
}
grupo.finish();
}
fn benchmark_ordenacao(c: &mut Criterion) {
let mut grupo = c.benchmark_group("Ordenação");
let dados: Vec<i32> = (0..1000).rev().collect();
grupo.bench_function("sort (Tim Sort)", |b| {
b.iter(|| {
let mut copia = dados.clone();
copia.sort();
black_box(copia)
})
});
grupo.bench_function("sort_unstable (Pattern Sort)", |b| {
b.iter(|| {
let mut copia = dados.clone();
copia.sort_unstable();
black_box(copia)
})
});
grupo.finish();
}
criterion_group!(benches, benchmark_fibonacci, benchmark_ordenacao);
criterion_main!(benches);
Execute os benchmarks:
# Executar todos os benchmarks
cargo bench
# Executar benchmark específico
cargo bench -- Fibonacci
# Os relatórios HTML ficam em target/criterion/
Workflow TDD (Test-Driven Development)
O TDD segue o ciclo Vermelho - Verde - Refatorar:
- Vermelho: escreva um teste que falha
- Verde: escreva o código mínimo para o teste passar
- Refatorar: melhore o código mantendo os testes passando
Exemplo Prático: Validador de CPF
// Passo 1 (Vermelho): escrever o teste primeiro
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpf_valido() {
assert!(validar_cpf("529.982.247-25"));
}
#[test]
fn cpf_valido_sem_formatacao() {
assert!(validar_cpf("52998224725"));
}
#[test]
fn cpf_invalido() {
assert!(!validar_cpf("111.111.111-11"));
}
#[test]
fn cpf_com_tamanho_errado() {
assert!(!validar_cpf("123"));
}
#[test]
fn cpf_vazio() {
assert!(!validar_cpf(""));
}
#[test]
fn cpf_todos_digitos_iguais() {
assert!(!validar_cpf("000.000.000-00"));
assert!(!validar_cpf("999.999.999-99"));
}
}
// Passo 2 (Verde): implementar a função
pub fn validar_cpf(cpf: &str) -> bool {
// Remover caracteres não numéricos
let digitos: Vec<u32> = cpf
.chars()
.filter(|c| c.is_ascii_digit())
.filter_map(|c| c.to_digit(10))
.collect();
// Verificar tamanho
if digitos.len() != 11 {
return false;
}
// Verificar se todos os dígitos são iguais
if digitos.windows(2).all(|w| w[0] == w[1]) {
return false;
}
// Calcular primeiro dígito verificador
let soma1: u32 = digitos[..9]
.iter()
.enumerate()
.map(|(i, &d)| d * (10 - i as u32))
.sum();
let resto1 = (soma1 * 10) % 11;
let dv1 = if resto1 == 10 { 0 } else { resto1 };
if dv1 != digitos[9] {
return false;
}
// Calcular segundo dígito verificador
let soma2: u32 = digitos[..10]
.iter()
.enumerate()
.map(|(i, &d)| d * (11 - i as u32))
.sum();
let resto2 = (soma2 * 10) % 11;
let dv2 = if resto2 == 10 { 0 } else { resto2 };
dv2 == digitos[10]
}
// Passo 3 (Refatorar): o código já está limpo, mas poderíamos extrair
// funções auxiliares se a complexidade crescer.
Conclusão
O ecossistema de testes do Rust oferece ferramentas completas para garantir a qualidade do código:
- Testes unitários (
#[test]) integrados diretamente nos módulos - Testes de integração no diretório
tests/para testar a API pública - Doc tests que servem simultaneamente como documentação e testes
- Mocking com
mockallpara isolar dependências externas - Benchmarks com
criterionpara medir e comparar performance - TDD como metodologia para desenvolver código robusto desde o início
A cultura de testes em Rust é forte. Ao executar cargo test, você roda testes unitários, de integração e doc tests em um único comando. Adote o hábito de escrever testes junto com seu código e sua base de código agradecerá com menos bugs e mais confiança em cada refatoração.