Testes em Rust

Guia completo sobre testes em Rust: testes unitários, testes de integração, doc tests, organização de testes, assertions, mocking com mockall, benchmarks com criterion e workflow TDD.

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:

  1. Vermelho: escreva um teste que falha
  2. Verde: escreva o código mínimo para o teste passar
  3. 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 mockall para isolar dependências externas
  • Benchmarks com criterion para 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.