pratica

Testes em Rust: Escrevendo Testes Unitários

Aprenda sobre testes em rust: escrevendo testes unitários

30 min
Aula 4 de 4

Testes em Rust: Escrevendo Testes Unitários

Olá, futuro mestre em Rust! 👋 Nesta aula prática, vamos mergulhar no mundo dos testes unitários em Rust, uma ferramenta essencial para garantir a robustez e a confiabilidade do seu código. Rust é uma linguagem que incentiva fortemente a escrita de testes, e você verá como é fácil e intuitivo fazê-lo.

1. Introdução aos Testes Unitários em Rust ✨

Testes unitários são pequenos pedaços de código que verificam se unidades individuais de código (como funções ou métodos) se comportam como esperado. Em Rust, eles são uma parte fundamental do processo de desenvolvimento, ajudando a prevenir bugs, refatorar com confiança e documentar o comportamento do código.

O Rust fornece um sistema de teste integrado que é fácil de usar e muito eficiente. Você não precisa de bibliotecas externas complexas para começar a testar; tudo o que você precisa já vem com o compilador e o gerenciador de pacotes cargo.

Nesta aula, você aprenderá a:

  • Escrever testes unitários básicos usando #[test].
  • Utilizar macros de asserção como assert!, assert_eq! e assert_ne!.
  • Testar condições de pânico com #[should_panic].
  • Testar funções que retornam Result<T, E>.
  • Executar seus testes com cargo test.

Vamos começar a codificar! 🚀

2. Explicação Detalhada com Exemplos 🧪

2.1. A Estrutura Básica de um Teste Unitário

Em Rust, os testes são funções anotadas com o atributo #[test]. Quando você executa cargo test, o Rust encontra e executa essas funções.

A convenção é colocar os testes unitários no mesmo arquivo que o código que eles estão testando, dentro de um módulo tests aninhado. Isso permite que os testes acessem itens privados do módulo externo, o que é útil para testar a lógica interna.

Vamos criar um novo projeto para nossos exemplos:

cargo new rust_tests_aula
cd rust_tests_aula

Agora, edite o arquivo src/lib.rs (ou src/main.rs se for um executável, mas lib.rs é mais comum para bibliotecas que serão testadas por outras partes).

// src/lib.rs
 
pub fn add_two(a: i32) -> i32 {
    a + 2
}
 
#[cfg(test)] // Esta linha garante que o módulo 'tests' só seja compilado durante os testes
mod tests {
    use super::*; // Importa tudo do módulo pai (neste caso, `add_two`)
 
    #[test] // Atributo que marca a função como um teste
    fn it_adds_two() {
        assert_eq!(4, add_two(2)); // Verifica se 4 é igual ao resultado de add_two(2)
    }
 
    #[test]
    fn another_test() {
        assert_ne!(5, add_two(2)); // Verifica se 5 NÃO é igual ao resultado de add_two(2)
    }
 
    #[test]
    fn basic_assertion() {
        let result = add_two(3);
        assert!(result > 4, "Resultado esperado maior que 4, mas foi {}", result); // Verifica uma condição booleana
    }
}

Explicação:

  • pub fn add_two(a: i32) -> i32: Uma função pública que queremos testar.
  • #[cfg(test)]: Esta anotação condicional significa que o módulo tests só será compilado quando você executar cargo test. Isso economiza espaço no binário final compilado para produção.
  • mod tests { ... }: Define um módulo para nossos testes.
  • use super::*;: Importa todas as definições do módulo pai (o lib.rs principal) para dentro do módulo tests. Isso nos permite usar add_two diretamente.
  • #[test]: Marca a função it_adds_two como uma função de teste.
  • assert_eq!(expected, actual): Macro que verifica se dois valores são iguais. Se não forem, o teste falha e imprime os dois valores.
  • assert_ne!(expected, actual): Macro que verifica se dois valores são diferentes. Se forem iguais, o teste falha.
  • assert!(boolean_expression, "Mensagem de erro"): Macro que verifica se uma expressão booleana é verdadeira. Se for falsa, o teste falha e a mensagem de erro opcional é exibida.

Para executar esses testes, salve o arquivo e execute no terminal:

cargo test

Você deverá ver uma saída similar a esta:

running 3 tests
test tests::another_test ... ok
test tests::basic_assertion ... ok
test tests::it_adds_two ... ok

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

2.2. Testando Condições de Pânico com #[should_panic]

Às vezes, queremos garantir que nosso código entre em pânico sob certas condições de erro. Para isso, usamos o atributo #[should_panic].

Você pode até especificar uma mensagem esperada para o pânico com expected = "mensagem". Isso é útil para garantir que o pânico ocorra pelo motivo certo.

Vamos adicionar uma função que pode entrar em pânico e um teste para ela em src/lib.rs:

// src/lib.rs (continue do exemplo anterior)
 
pub struct Guess {
    value: i32,
}
 
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }
 
        Guess { value }
    }
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    // ... testes anteriores ...
 
    #[test]
    #[should_panic] // Este teste espera que a função `new` entre em pânico
    fn greater_than_100_panics() {
        Guess::new(200);
    }
 
    #[test]
    #[should_panic(expected = "Guess value must be between 1 and 100, got 0.")] // Espera uma mensagem de pânico específica
    fn less_than_1_panics_with_message() {
        Guess::new(0);
    }
}

Execute cargo test novamente. Você verá que os novos testes de pânico também são bem-sucedidos, pois eles esperam o pânico. Se a função não entrasse em pânico, o teste #[should_panic] falharia.

2.3. Testando Funções que Retornam Result<T, E>

Testar funções que retornam Result<T, E> é um pouco diferente. Você pode usar a sintaxe ? dentro de um teste se o teste também retornar Result<(), E>. Isso permite que você propague erros de forma concisa.

Vamos adicionar uma função que retorna Result e um teste para ela em src/lib.rs:

// src/lib.rs (continue do exemplo anterior)
 
use std::fmt;
 
#[derive(Debug, PartialEq)]
pub enum MyError {
    InputTooSmall,
    InputTooLarge,
}
 
impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MyError::InputTooSmall => write!(f, "Input is too small!"),
            MyError::InputTooLarge => write!(f, "Input is too large!"),
        }
    }
}
 
pub fn check_value(value: i32) -> Result<String, MyError> {
    if value < 10 {
        Err(MyError::InputTooSmall)
    } else if value > 100 {
        Err(MyError::InputTooLarge)
    } else {
        Ok(format!("Value {} is within range.", value))
    }
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    // ... testes anteriores ...
 
    #[test]
    fn check_value_in_range_ok() -> Result<(), MyError> { // O teste agora retorna Result
        let result = check_value(50)?; // Usa '?' para propagar o erro se houver
        assert_eq!(result, "Value 50 is within range.".to_string());
        Ok(()) // Retorna Ok(()) se o teste for bem-sucedido
    }
 
    #[test]
    fn check_value_too_small_err() {
        let result = check_value(5);
        assert!(result.is_err()); // Verifica se o resultado é um erro
        assert_eq!(result.unwrap_err(), MyError::InputTooSmall); // Desempacota o erro e verifica o tipo
    }
 
    #[test]
    fn check_value_too_large_err() {
        let result = check_value(150);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), MyError::InputTooLarge);
    }
}

Observações sobre o teste de Result:

  • fn check_value_in_range_ok() -> Result<(), MyError>: O teste agora retorna um Result. Isso permite que você use o operador ?.
  • let result = check_value(50)?;: Se check_value(50) retornar um Err, o ? fará com que o teste retorne imediatamente esse Err, falhando o teste. Se retornar Ok, ele desempacota o valor.
  • Ok(()): Se todas as asserções passarem e nenhum erro for propagado, o teste retorna Ok(()), indicando sucesso.
  • Para testar casos de erro, você pode usar is_err() e unwrap_err() para acessar o erro e verificar seu tipo ou mensagem.

Execute cargo test novamente para ver todos os testes passando.

2.4. Executando Testes Específicos e Opções do cargo test

O cargo test é muito versátil. Aqui estão algumas opções úteis:

  • Executar todos os testes:

    cargo test
  • Executar um teste específico (pelo nome):

    cargo test check_value_in_range_ok

    Você pode usar partes do nome também:

    cargo test panics

    Isso executaria todos os testes que contêm "panics" no nome, como greater_than_100_panics e less_than_1_panics_with_message.

  • Mostrar a saída de println! nos testes: Por padrão, os testes em Rust capturam a saída de println!. Se você quiser ver essa saída (útil para depuração), use a flag -- --nocapture:

    cargo test -- --nocapture

    (Note o -- que separa as opções do cargo das opções do executor de testes).

  • Executar testes sequencialmente (não em paralelo): Por padrão, cargo test executa os testes em paralelo. Se você tiver testes que dependem de estado compartilhado ou recursos externos, pode ser necessário executá-los um por um:

    cargo test -- --test-threads=1
  • Ignorar testes: Você pode marcar testes para serem ignorados com #[ignore]. Isso é útil para testes caros ou que ainda estão em desenvolvimento.

    // src/lib.rs
    // ...
    #[cfg(test)]
    mod tests {
        // ...
        #[test]
        #[ignore] // Este teste será ignorado por padrão
        fn expensive_test() {
            // Este teste leva muito tempo para executar
            assert!(true);
        }
    }

    Para executar os testes ignorados, use -- --ignored:

    cargo test -- --ignored

    Para executar todos os testes, incluindo os ignorados:

    cargo test -- --include-ignored

3. Exercícios/Desafios Práticos ✅

Agora é a sua vez de colocar a mão na massa! Crie um novo projeto Rust e implemente as seguintes funcionalidades, escrevendo testes unitários para cada uma delas.

Crie um novo projeto:

cargo new my_math_lib
cd my_math_lib

Edite o arquivo src/lib.rs para incluir suas implementações e testes.

Tarefas:

  • 1. Função is_even:

    • Crie uma função pub fn is_even(num: i32) -> bool que retorne true se o número for par e false caso contrário.
    • Escreva pelo menos 3 testes unitários para esta função:
      • Um para um número par.
      • Um para um número ímpar.
      • Um para o número zero.
  • 2. Função factorial:

    • Crie uma função pub fn factorial(n: u32) -> u128 que calcule o fatorial de um número n.
    • Escreva pelo menos 4 testes unitários para esta função:
      • factorial(0) deve ser 1.
      • factorial(1) deve ser 1.
      • factorial(5) deve ser 120.
      • Um teste com #[should_panic] para factorial se você decidir que números negativos devem causar pânico (embora a assinatura u32 já previna isso, você pode adaptar para i32 para este teste, ou criar uma condição de pânico para um u32 muito grande que causaria overflow se você não usasse u128). Dica: para u32, você pode simular um pânico se o número for excessivamente grande para a capacidade de cálculo, ou simplesmente testar os casos normais.
  • 3. Função divide com Result:

    • Crie uma função pub fn divide(numerator: f64, denominator: f64) -> Result<f64, String> que divida dois números.
    • Se o denominador for zero, a função deve retornar um Err com uma mensagem apropriada (ex: "Cannot divide by zero").
    • Escreva pelo menos 3 testes unitários para esta função:
      • Um para uma divisão bem-sucedida (ex: divide(10.0, 2.0)).
      • Um para o caso de divisão por zero que retorne Err.
      • Um teste que use o operador ? no teste para uma divisão bem-sucedida.
  • 4. Experimente com cargo test:

    • Execute todos os seus testes.
    • Execute apenas os testes da função factorial.
    • Execute os testes com a opção -- --nocapture e adicione um println! dentro de um dos seus testes para ver a saída.

Sinta-se à vontade para adicionar mais testes e explorar outros cenários!

4. Resumo e Próximos Passos 🚀

Parabéns! 🎉 Você deu os primeiros passos cruciais no mundo dos testes unitários em Rust. Nesta aula, você aprendeu:

  • A importância dos testes unitários para a qualidade do código.
  • Como estruturar seus testes usando #[cfg(test)] e mod tests.
  • A utilizar as macros assert!, assert_eq! e assert_ne! para verificar o comportamento do código.
  • A testar condições de erro e pânico com #[should_panic].
  • A lidar com testes que retornam Result<T, E>.
  • A usar cargo test e suas opções para gerenciar a execução dos testes.

Dominar os testes unitários é fundamental para escrever código Rust robusto e confiável. Eles são a primeira linha de defesa contra bugs e uma ferramenta poderosa para a refatoração.

Próximos Passos:

  • Testes de Integração: No próximo tópico, exploraremos como escrever testes de integração, que testam a interação entre diferentes partes do seu crate ou com crates externas.
  • Testes de Documentação: Rust também permite escrever testes diretamente nos exemplos da sua documentação, garantindo que a documentação esteja sempre atualizada e correta.
  • Testes de Benchmark: Para medir o desempenho do seu código.

Continue praticando e explorando! A capacidade de testar seu código eficientemente o tornará um desenvolvedor Rust muito mais produtivo e confiante. Até a próxima! 👋

© 2025 Escola All Dev. Todos os direitos reservados.

Testes em Rust: Escrevendo Testes Unitários - Curso gratuito de Rust: A linguagem mais amada | escola.all.dev.br