Testes são uma parte fundamental do desenvolvimento de software, e Rust oferece suporte de primeira classe para escrever testes automatizados. Eles ajudam a garantir que seu código se comporte como esperado, facilitam a refatoração e aumentam a confiança na qualidade do software. Nesta aula, vamos explorar os conceitos básicos de testes em Rust: como escrever funções de teste com o atributo #[test], como usar a macro assert! para verificar condições, como executar os testes com o comando cargo test e como organizar testes de unidade dentro do seu projeto.

Além disso, veremos exemplos práticos e boas práticas para criar testes eficazes. Ao final, você terá uma base sólida para incorporar testes em seus projetos Rust, desde pequenas bibliotecas até aplicações complexas.

#[test]

Em Rust, o atributo #[test] é usado para marcar uma função como um teste. O compilador e o sistema de testes do Rust reconhecem essas funções e as executam quando você roda o comando cargo test. Uma função de teste é uma função comum que não recebe argumentos e não retorna nada (ou retorna um Result, como veremos mais adiante).

Para criar um teste, basta adicionar o atributo #[test] antes da definição da função. Por exemplo:

#[test]
fn test_soma() {
    // código do teste
}

Você pode colocar testes em qualquer lugar do seu código, mas é comum colocá-los em um módulo tests dentro do mesmo arquivo ou em um arquivo separado na pasta tests/. O atributo #[test] só é compilado quando você executa os testes, então não há impacto no código de produção.

Se uma função de teste não for marcada com #[test], ela não será executada pelo cargo test. Além disso, o atributo #[test] pode ser combinado com outros atributos, como #[should_panic] para testar situações de erro.

Vamos ver um exemplo completo de uma função de teste simples:

fn soma(a: i32, b: i32) -> i32 {
    a + b
}

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

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

Neste exemplo, o módulo tests é anotado com #[cfg(test)], o que faz com que ele seja compilado apenas durante a execução dos testes. A função test_soma verifica se a função soma retorna o valor esperado.

assert!

A macro assert! é usada para verificar se uma condição é verdadeira durante a execução do teste. Se a condição for falsa, a macro faz o teste falhar e exibe uma mensagem de erro. A sintaxe é:

assert!(expressão, "mensagem opcional", argumentos...);

A expressão deve ser uma condição booleana. Se ela for true, o teste continua; se for false, o teste falha e a mensagem (se fornecida) é exibida no console. Por exemplo:

#[test]
fn test_maioridade() {
    let idade = 18;
    assert!(idade >= 18, "A idade deve ser maior ou igual a 18, mas é {}", idade);
}

Existem variações da macro assert! que são muito úteis:

  • assert_eq!(a, b): verifica se a e b são iguais. Se não forem, exibe uma mensagem mostrando os valores de a e b.
  • assert_ne!(a, b): verifica se a e b são diferentes.
  • debug_assert! e variações: são como as macros assert!, mas são compiladas apenas em modo debug (desenvolvimento). Elas são úteis para verificações que não devem impactar o desempenho em produção.

Exemplo com assert_eq!:

#[test]
fn test_multiplicacao() {
    let resultado = 4 * 5;
    assert_eq!(resultado, 20);
}

É importante usar assert_eq! e assert_ne! sempre que possível, pois eles fornecem mensagens de erro mais informativas, mostrando os valores reais e esperados.

cargo test

O comando cargo test é a forma padrão de executar todos os testes do seu projeto Rust. Ele compila o código (incluindo os testes) e executa cada função marcada com #[test]. Se algum teste falhar, o cargo exibe detalhes sobre a falha, como o nome do teste, a mensagem de erro e o local do erro.

Quando você executa cargo test, o cargo também executa testes de documentação (se houver), testes de integração (na pasta tests/) e testes de unidade dentro do código. O resultado é apresentado em uma saída resumida, com um contador de testes que passaram, falharam e foram ignorados.

Exemplo de saída:

running 2 tests
test tests::test_soma ... ok
test tests::test_multiplicacao ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Algumas opções úteis do cargo test:

  • cargo test nome_do_teste: executa apenas os testes que contêm a substring nome_do_teste no nome.
  • cargo test -- --nocapture: exibe a saída de println! e outras macros de saída nos testes.
  • cargo test -- --ignored: executa apenas os testes marcados com #[ignore].
  • cargo test --release: executa os testes em modo release, com otimizações.

Para marcar um teste como ignorado, você pode usar o atributo #[ignore]:

#[test]
#[ignore]
fn teste_lento() {
    // teste demorado
}

Isso é útil para testes que são lentos ou que dependem de recursos externos.

Testes de unidade

Testes de unidade são testes que verificam o comportamento de uma unidade específica de código, geralmente uma função ou um módulo. Em Rust, a prática comum é colocar testes de unidade no mesmo arquivo que o código, em um módulo tests anotado com #[cfg(test)]. Isso permite que os testes acessem funções privadas e internas do módulo, facilitando a verificação de detalhes de implementação.

Por exemplo, suponha que temos um módulo matematica com uma função privada dobro:

mod matematica {
    fn dobro(x: i32) -> i32 {
        x * 2
    }

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

        #[test]
        fn test_dobro() {
            assert_eq!(dobro(5), 10);
        }
    }
}

Observe que usamos use super::*; para trazer as funções do módulo pai para o escopo do teste. Isso permite que os testes acessem funções privadas.

Além dos testes de unidade, o Rust também suporta testes de integração, que são colocados na pasta tests/ e testam o comportamento do código como uma biblioteca externa. No entanto, o foco desta aula são os testes de unidade.

Uma boa prática é escrever testes para cada função pública e, se necessário, para funções privadas que tenham lógica complexa. Os testes devem cobrir casos normais, casos de borda e casos de erro.

Exemplo de uma função com múltiplos testes:

fn dividir(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("Divisão por zero"))
    } 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_eq!(dividir(10.0, 0.0), Err(String::from("Divisão por zero")));
    }
}

Neste exemplo, testamos tanto o caso de sucesso quanto o caso de erro, garantindo que a função se comporte corretamente em ambas as situações.

Boas práticas

Ao escrever testes, siga estas boas práticas:

  • Escreva testes pequenos e focados: cada teste deve verificar um comportamento específico.
  • Nomeie os testes de forma descritiva, indicando o que estão testando.
  • Use assert_eq! e assert_ne! em vez de assert! sempre que possível.
  • Teste casos de borda, como valores extremos, entradas vazias ou condições de erro.
  • Mantenha os testes independentes: um teste não deve depender de outro para passar.
  • Execute os testes com frequência para detectar regressões rapidamente.

Além disso, é importante lembrar que testes são parte do código e devem ser mantidos com o mesmo cuidado que o código de produção.

Exercícios

  1. Escreva uma função eh_par que recebe um número inteiro e retorna true se for par, false caso contrário. Crie um teste de unidade para verificar o comportamento da função com números pares e ímpares.
  2. ✓ Resposta:
    fn eh_par(n: i32) -> bool {
        n % 2 == 0
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_eh_par() {
            assert!(eh_par(4));
            assert!(!eh_par(3));
        }
    }
    
  3. Crie uma função maior que recebe dois números inteiros e retorna o maior. Escreva testes para verificar o comportamento com números positivos, negativos e iguais.
  4. ✓ Resposta:
    fn maior(a: i32, b: i32) -> i32 {
        if a > b { a } else { b }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_maior() {
            assert_eq!(maior(5, 3), 5);
            assert_eq!(maior(-2, -7), -2);
            assert_eq!(maior(4, 4), 4);
        }
    }
    
  5. Implemente uma função fatorial que calcula o fatorial de um número não negativo. Use assert! para verificar que a entrada é não negativa. Escreva testes para casos como 0, 1, 5 e 10.
  6. ✓ Resposta:
    fn fatorial(n: u64) -> u64 {
        assert!(n >= 0, "Fatorial não definido para números negativos");
        if n == 0 {
            1
        } else {
            n * fatorial(n - 1)
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_fatorial() {
            assert_eq!(fatorial(0), 1);
            assert_eq!(fatorial(1), 1);
            assert_eq!(fatorial(5), 120);
            assert_eq!(fatorial(10), 3628800);
        }
    }
    
  7. Escreva uma função inverter_string que recebe uma string e retorna a string invertida. Crie testes de unidade para verificar a inversão de palavras e frases.
  8. ✓ Resposta:
    fn inverter_string(s: &str) -> String {
        s.chars().rev().collect()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_inverter_string() {
            assert_eq!(inverter_string("hello"), "olleh");
            assert_eq!(inverter_string("Rust"), "tsuR");
            assert_eq!(inverter_string(""), "");
        }
    }
    
  9. Utilize o atributo #[should_panic] para escrever um teste que verifica que uma função dividir (que retorna f64 diretamente, sem Result) entra em pânico quando o divisor é zero. Implemente a função e o teste.
  10. ✓ Resposta:
    fn dividir(a: f64, b: f64) -> f64 {
        if b == 0.0 {
            panic!("Divisão por zero");
        }
        a / b
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        #[should_panic(expected = "Divisão por zero")]
        fn test_dividir_por_zero() {
            dividir(10.0, 0.0);
        }
    }
    

Referências