Testes em Rust
Nesta aula, você aprenderá a escrever e executar testes em Rust, cobrindo desde a criação de testes de unidade com a macro #[test] até o uso de macros de asserção como assert!, assert_eq! e assert_ne!. Além disso, verá como o cargo test organiza e executa os testes, e boas práticas para manter seus testes eficientes e confiáveis.
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
}
Já 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!eassert_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
- Escreva uma função chamada
maiorque receba dois números inteiros e retorne o maior. Crie testes de unidade para verificar o comportamento com números positivos, negativos e iguais. - Escreva uma função
eh_palindromoque verifique se uma string é um palíndromo (ignorando espaços e maiúsculas). Useassert!para testar com exemplos como "arara" e "banana". - Crie uma função
fatorialque calcule o fatorial de um número inteiro não negativo. Useassert_eq!para testar com 0, 1, 5 e também verifique que a função retornaNonepara números negativos (useOption<u64>). - Escreva uma função
mediaque receba um slice de números flutuantes e retorne a média. Se o slice for vazio, retorneNone. Teste com uma lista de números e com lista vazia. - Usando
cargo test, escreva um teste que verifique que a funçãodividirretorna um erro ao dividir por zero. Useassert!(resultado.is_err())e tambémassert_eq!(resultado, Err("Divisão por zero".to_string())).
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);
}
}
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"));
}
}
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);
}
}
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);
}
}
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()));
}
}