Testes são uma parte fundamental do desenvolvimento de software, e Rust oferece um suporte excelente para escrever testes de forma integrada ao seu código. Nesta aula, vamos explorar como criar testes de unidade usando o atributo #[test], como verificar condições com a macro assert! e suas variantes, como executar os testes com cargo test e como organizar testes no mesmo arquivo ou em módulos separados. Você verá que os testes em Rust são simples de escrever e podem ser executados rapidamente, o que incentiva a prática de testar desde o início do desenvolvimento.

Além disso, vamos discutir a importância dos testes para garantir que o código funcione como esperado, facilitar a manutenção e evitar regressões. Com exemplos práticos, você aprenderá a testar funções, lidar com erros esperados e organizar seus testes de forma eficiente.

#[test]

Em Rust, a forma mais comum de escrever testes de unidade é usar o atributo #[test] em uma função. Quando você anota uma função com #[test], o compilador a trata como um teste e a executa quando você roda cargo test. Essa função deve retornar () ou Result<(), E>, onde E é um tipo que implementa Debug. Se a função entrar em pânico (panic) ou retornar Err, o teste falha.

Vamos ver um exemplo simples. Crie um novo projeto com cargo new meu_projeto e, no arquivo src/lib.rs (ou src/main.rs), escreva:

// src/lib.rs
pub fn soma(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_soma() {
        assert_eq!(soma(2, 2), 4);
    }
}

No exemplo, a função soma é pública e pode ser usada fora do módulo. O módulo tests é anotado com #[cfg(test)], o que significa que ele só será compilado quando você estiver rodando testes. Dentro dele, a função test_soma é marcada com #[test]. A macro assert_eq! verifica se soma(2,2) é igual a 4; se não for, o teste falha.

Para executar os testes, use cargo test. Você verá uma saída semelhante a:

running 1 test
test tests::test_soma ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Você também pode escrever testes em um arquivo separado dentro da pasta tests/ para testes de integração, mas aqui focaremos nos testes de unidade.

assert!

A macro assert! é usada para verificar se uma condição é verdadeira. Se a condição for falsa, a macro faz o programa entrar em pânico com uma mensagem de erro que inclui a expressão e o local do código. Existem variações: assert_eq! para verificar igualdade entre dois valores, assert_ne! para verificar desigualdade, e debug_assert! que só é ativada em builds de debug.

Vamos ver um exemplo com assert!:

fn eh_par(num: i32) -> bool {
    num % 2 == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_eh_par() {
        assert!(eh_par(4), "4 deveria ser par");
        assert!(!eh_par(3), "3 não deveria ser par");
    }
}

No código, a função eh_par retorna true se o número for par. No teste, usamos assert! para verificar que eh_par(4) é verdadeiro e que eh_par(3) é falso. A mensagem opcional após a condição é exibida se o teste falhar, ajudando a diagnosticar o problema.

Com assert_eq!, você pode comparar dois valores e, se forem diferentes, a macro exibe os dois valores no relatório de falha. Exemplo:

#[test]
fn test_soma() {
    assert_eq!(soma(2, 2), 4); // passa
    assert_eq!(soma(2, 2), 5); // falha: left: 4, right: 5
}

assert_ne! verifica se dois valores são diferentes. É útil quando você quer garantir que um valor não seja igual a outro.

cargo test

O comando cargo test é a forma padrão de compilar e executar todos os testes do seu projeto. Ele compila o código com a configuração de teste, executa os testes em paralelo (por padrão) e reporta os resultados. Você pode passar opções para filtrar testes, controlar o número de threads, e muito mais.

Alguns comandos úteis:

  • cargo test – executa todos os testes.
  • cargo test nome_do_teste – executa apenas testes cujo nome contém a string fornecida.
  • cargo test -- --nocapture – mostra a saída padrão (println!) dos testes, útil para depuração.
  • cargo test -- --test-threads=1 – executa os testes em uma única thread, útil para testes que dependem de estado global.

Exemplo de filtro por nome:

cargo test test_soma

Isso executará apenas os testes que contenham "test_soma" no nome. Se você quiser ver a saída detalhada de cada teste, use --nocapture.

Além disso, o cargo test também compila e executa testes de integração que você coloca na pasta tests/ (cada arquivo .rs nessa pasta é um crate separado). Para testes de documentação, o cargo também os executa, garantindo que os exemplos nos comentários estejam corretos.

Testes de unidade

Testes de unidade são testes que verificam o comportamento de uma unidade isolada de código, geralmente uma função ou um módulo. Em Rust, é comum colocar os testes de unidade no mesmo arquivo do código, dentro de um módulo tests anotado com #[cfg(test)]. Isso permite que os testes acessem funções privadas facilmente, usando use super::*; para importar os itens do módulo pai.

Vamos criar um exemplo mais completo. Suponha que temos uma calculadora com funções básicas:

// src/lib.rs
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)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_dividir_sucesso() {
        assert_eq!(dividir(10.0, 2.0), Ok(5.0));
    }

    #[test]
    fn test_dividir_por_zero() {
        assert!(dividir(1.0, 0.0).is_err());
    }

    #[test]
    #[should_panic(expected = "Divisão por zero")]
    fn test_dividir_panico() {
        dividir(1.0, 0.0).unwrap();
    }
}

No exemplo, testamos o caso de sucesso e o caso de erro. Usamos assert_eq! para comparar o resultado com o esperado. O atributo #[should_panic(expected = "...")] indica que o teste deve entrar em pânico com a mensagem esperada; se não entrar, o teste falha. Isso é útil para verificar que um erro é tratado com pânico.

Outra prática comum é usar o padrão Arrange-Act-Assert (AAA) para organizar os testes: preparar os dados (Arrange), executar a ação (Act) e verificar o resultado (Assert). Isso melhora a legibilidade.

Além disso, você pode agrupar testes relacionados em sub-módulos, se necessário, mas para a maioria dos casos, um módulo tests por arquivo é suficiente.

Boas práticas

Ao escrever testes em Rust, considere as seguintes boas práticas:

  • Escreva testes para funções críticas, especialmente aquelas com lógica complexa.
  • Use nomes descritivos para os testes, como test_soma_com_numeros_negativos.
  • Teste casos de borda, como divisão por zero, valores vazios, entradas máximas/mínimas.
  • Mantenha os testes independentes: cada teste deve ser executado isoladamente e não depender de outros.
  • Use assert_eq! e assert_ne! quando possível, pois fornecem melhores mensagens de erro.
  • Se o teste for lento ou exigir recursos externos, considere marcá-lo com #[ignore] para não rodar por padrão.

Exercícios

  1. Escreva uma função chamada maior que receba dois números inteiros e retorne o maior. Crie testes de unidade para verificar o comportamento com números positivos, negativos e iguais.
  2. ✓ Resposta:
    pub fn maior(a: i32, b: i32) -> i32 {
        if a > b { a } else { b }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_maior_positivos() {
            assert_eq!(maior(3, 5), 5);
        }
    
        #[test]
        fn test_maior_negativos() {
            assert_eq!(maior(-3, -5), -3);
        }
    
        #[test]
        fn test_maior_iguais() {
            assert_eq!(maior(7, 7), 7);
        }
    }
    
  3. Escreva uma função eh_palindromo que verifique se uma string é um palíndromo (ignorando espaços e maiúsculas). Use assert! para testar com exemplos como "arara" e "banana".
  4. ✓ Resposta:
    pub fn eh_palindromo(s: &str) -> bool {
        let s = s.to_lowercase();
        let s = s.replace(' ', "");
        let rev = s.chars().rev().collect::<String>();
        s == rev
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_palindromo() {
            assert!(eh_palindromo("arara"));
            assert!(eh_palindromo("Socorram me subi no onibus em Marrocos"));
        }
    
        #[test]
        fn test_nao_palindromo() {
            assert!(!eh_palindromo("banana"));
        }
    }
    
  5. Crie uma função fatorial que calcule o fatorial de um número inteiro não negativo. Use assert_eq! para testar com 0, 1, 5 e também verifique que a função retorna None para números negativos (use Option<u64>).
  6. ✓ Resposta:
    pub fn fatorial(n: i32) -> Option<u64> {
        if n < 0 {
            return None;
        }
        let mut resultado: u64 = 1;
        for i in 2..=n as u64 {
            resultado *= i;
        }
        Some(resultado)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_fatorial_0() {
            assert_eq!(fatorial(0), Some(1));
        }
    
        #[test]
        fn test_fatorial_1() {
            assert_eq!(fatorial(1), Some(1));
        }
    
        #[test]
        fn test_fatorial_5() {
            assert_eq!(fatorial(5), Some(120));
        }
    
        #[test]
        fn test_fatorial_negativo() {
            assert_eq!(fatorial(-1), None);
        }
    }
    
  7. Escreva uma função media que receba um slice de números flutuantes e retorne a média. Se o slice for vazio, retorne None. Teste com uma lista de números e com lista vazia.
  8. ✓ Resposta:
    pub fn media(numeros: &[f64]) -> Option<f64> {
        if numeros.is_empty() {
            return None;
        }
        let soma: f64 = numeros.iter().sum();
        Some(soma / numeros.len() as f64)
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_media_normal() {
            assert_eq!(media(&[2.0, 4.0, 6.0]), Some(4.0));
        }
    
        #[test]
        fn test_media_vazia() {
            assert_eq!(media(&[]), None);
        }
    }
    
  9. Usando cargo test, escreva um teste que verifique que a função dividir retorna um erro ao dividir por zero. Use assert!(resultado.is_err()) e também assert_eq!(resultado, Err("Divisão por zero".to_string())).
  10. ✓ Resposta:
    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)
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_dividir_por_zero() {
            let resultado = dividir(1.0, 0.0);
            assert!(resultado.is_err());
            assert_eq!(resultado, Err("Divisão por zero".to_string()));
        }
    }
    

Referências