Rust

Testes — Escrevendo Código que Prova que Seu Código Funciona Já leu

12 min de leitura

Testes — Escrevendo Código que Prova que Seu Código Funciona
  Rust tem suporte nativo a testes — sem frameworks externos, sem configuração adicional. O compilador e o Cargo sabem o que sã

 

Rust tem suporte nativo a testes — sem frameworks externos, sem configuração adicional. O compilador e o Cargo sabem o que são testes, como executá-los e como reportar resultados. Essa integração não é acidental: a cultura Rust valoriza profundamente a correção do software, e testes são parte central dessa cultura.

Neste artigo vamos cobrir testes unitários, testes de integração, organização de testes em projetos reais, e algumas técnicas avançadas que tornam seus testes mais expressivos.


Testes unitários — a forma mais simples

Um teste em Rust é qualquer função anotada com #[test]. O Cargo os encontra e executa automaticamente:

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

fn dividir(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

#[cfg(test)]
mod tests {
    use super::*; // importa tudo do módulo pai

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

    #[test]
    fn teste_somar_negativos() {
        assert_eq!(somar(-2, -3), -5);
    }

    #[test]
    fn teste_somar_zero() {
        assert_eq!(somar(0, 5), 5);
        assert_eq!(somar(5, 0), 5);
    }

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

    #[test]
    fn teste_dividir_por_zero() {
        assert_eq!(dividir(10.0, 0.0), None);
    }
}

Execute com:

cargo test

Saída:

running 5 tests
test tests::teste_somar_negativos ... ok
test tests::teste_somar_positivos ... ok
test tests::teste_somar_zero ... ok
test tests::teste_dividir_normal ... ok
test tests::teste_dividir_por_zero ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured

O bloco #[cfg(test)] instrui o compilador a incluir esse módulo apenas em compilações de teste — não vai para o binário de produção. use super::* importa tudo do módulo pai para que o código de teste tenha acesso às funções que testa.


As macros de asserção

Rust oferece várias macros de asserção com mensagens de erro informativas:

#[cfg(test)]
mod tests {
    #[test]
    fn demonstrar_assercoes() {
        // assert! — verifica condição booleana
        assert!(2 + 2 == 4);
        assert!(!"hello".is_empty());

        // assert_eq! — verifica igualdade
        assert_eq!(2 + 2, 4);
        assert_eq!("hello".to_uppercase(), "HELLO");

        // assert_ne! — verifica desigualdade
        assert_ne!(2 + 2, 5);
        assert_ne!("hello", "world");

        // Mensagens customizadas de falha
        let x = 42;
        assert_eq!(x, 42, "esperava 42, mas x era {x}");

        assert!(
            x > 0,
            "x deveria ser positivo, mas era {}",
            x
        );
    }

    #[test]
    fn comparar_floats() {
        let resultado = 0.1 + 0.2;
        // Floats nunca devem ser comparados com ==
        // Use uma margem de tolerância
        let tolerancia = 1e-10;
        assert!(
            (resultado - 0.3).abs() < tolerancia,
            "Esperava ~0.3, mas obteve {resultado}"
        );
    }
}

Quando um assert_eq! falha, Rust exibe ambos os valores automaticamente:

thread 'tests::meu_teste' panicked at 'assertion failed: `(left == right)`
  left: `5`,
 right: `6`'

Testes que devem entrar em pânico

Às vezes você quer verificar que uma função entra em pânico sob certas condições. Use #[should_panic]:

fn dividir_inteiro(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("Divisão por zero!");
    }
    a / b
}

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

    #[test]
    #[should_panic(expected = "Divisão por zero")]
    fn teste_panic_divisao() {
        dividir_inteiro(10, 0);
    }

    #[test]
    fn teste_divisao_normal() {
        assert_eq!(dividir_inteiro(10, 2), 5);
    }
}

O atributo expected verifica que a mensagem do panic contém a string especificada. Sem expected, qualquer panic faz o teste passar — o que pode mascarar panics inesperados.


Testes com Result

Testes podem retornar Result<(), E>, permitindo o uso do operador ?:

use std::num::ParseIntError;

fn parse_e_dobrar(s: &str) -> Result<i32, ParseIntError> {
    let n: i32 = s.trim().parse()?;
    Ok(n * 2)
}

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

    #[test]
    fn teste_parse_valido() -> Result<(), ParseIntError> {
        let resultado = parse_e_dobrar("21")?;
        assert_eq!(resultado, 42);
        Ok(())
    }

    #[test]
    fn teste_parse_invalido() {
        let resultado = parse_e_dobrar("abc");
        assert!(resultado.is_err());
    }
}

Quando um teste retorna Err, ele falha com a mensagem de erro — sem precisar de unwrap ou assert.


Controlando a execução dos testes

O Cargo oferece vários modificadores para controlar como os testes rodam:

# Rodar apenas testes com "somar" no nome
cargo test somar

# Rodar todos os testes, incluindo ignorados
cargo test -- --include-ignored

# Rodar testes sequencialmente (sem paralelismo)
cargo test -- --test-threads=1

# Mostrar output de println! nos testes
cargo test -- --nocapture

# Listar todos os testes sem executar
cargo test -- --list

Por padrão, println! dentro de testes é suprimido — só aparece quando o teste falha. Use --nocapture para ver a saída sempre.


Ignorando testes temporariamente

Use #[ignore] para marcar testes lentos ou não implementados:

#[cfg(test)]
mod tests {
    #[test]
    fn teste_rapido() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    #[ignore = "teste muito lento — roda apenas em CI"]
    fn teste_lento() {
        // Simula operação demorada
        std::thread::sleep(std::time::Duration::from_secs(5));
        assert!(true);
    }

    #[test]
    #[ignore = "ainda não implementado"]
    fn teste_futuro() {
        todo!("implementar quando a feature X estiver pronta")
    }
}

Testes de integração

Testes unitários verificam partes isoladas do código. Testes de integração verificam que as partes funcionam juntas, usando o crate como um cliente externo usaria.

Eles vivem no diretório tests/ na raiz do projeto:

meu_projeto/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   └── calculadora.rs
└── tests/
    ├── integracao_calculadora.rs
    └── integracao_relatorio.rs

src/lib.rs:

pub mod calculadora;

src/calculadora.rs:

pub fn somar(a: f64, b: f64) -> f64 { a + b }
pub fn subtrair(a: f64, b: f64) -> f64 { a - b }
pub fn multiplicar(a: f64, b: f64) -> f64 { a * b }
pub fn dividir(a: f64, b: f64) -> Option<f64> {
    if b == 0.0 { None } else { Some(a / b) }
}

pub fn media(valores: &[f64]) -> Option<f64> {
    if valores.is_empty() {
        return None;
    }
    Some(valores.iter().sum::<f64>() / valores.len() as f64)
}

tests/integracao_calculadora.rs:

// Testes de integração usam o crate como dependência externa
use meu_projeto::calculadora;

#[test]
fn operacoes_basicas_encadeadas() {
    let a = calculadora::somar(10.0, 5.0);      // 15
    let b = calculadora::multiplicar(a, 2.0);   // 30
    let c = calculadora::subtrair(b, 6.0);      // 24
    let resultado = calculadora::dividir(c, 4.0); // Some(6)

    assert_eq!(resultado, Some(6.0));
}

#[test]
fn media_de_resultados() {
    let valores = vec![
        calculadora::somar(1.0, 2.0),      // 3
        calculadora::multiplicar(2.0, 3.0), // 6
        calculadora::subtrair(10.0, 1.0),  // 9
    ];

    let media = calculadora::media(&valores);
    assert_eq!(media, Some(6.0));
}

#[test]
fn divisao_por_zero_retorna_none() {
    assert_eq!(calculadora::dividir(100.0, 0.0), None);
}

#[test]
fn media_de_lista_vazia() {
    let lista: Vec<f64> = vec![];
    assert_eq!(calculadora::media(&lista), None);
}

Organizando testes com módulos auxiliares

Em testes de integração, código compartilhado entre arquivos vai em tests/common/mod.rs:

tests/
├── common/
│   └── mod.rs      ← utilitários compartilhados
├── integracao_calculadora.rs
└── integracao_relatorio.rs

tests/common/mod.rs:

// Helpers compartilhados entre testes de integração

pub fn aproximadamente_igual(a: f64, b: f64) -> bool {
    (a - b).abs() < 1e-10
}

pub fn criar_lista_teste() -> Vec<f64> {
    vec![1.0, 2.0, 3.0, 4.0, 5.0]
}

#[allow(dead_code)]
pub struct Contexto {
    pub nome: String,
    pub valores: Vec<f64>,
}

impl Contexto {
    pub fn novo(nome: &str) -> Self {
        Contexto {
            nome: nome.to_string(),
            valores: criar_lista_teste(),
        }
    }
}

Usando em testes:

mod common;

use meu_projeto::calculadora;

#[test]
fn teste_com_helper() {
    let ctx = common::Contexto::novo("teste_media");
    let media = calculadora::media(&ctx.valores).unwrap();
    assert!(common::aproximadamente_igual(media, 3.0));
}

Um projeto completo com testes abrangentes

Vamos criar um módulo de validação com cobertura completa de testes:

src/validacao.rs:

#[derive(Debug, PartialEq)]
pub enum ErroValidacao {
    CampoVazio(String),
    TamanhoInvalido { campo: String, min: usize, max: usize },
    FormatoInvalido(String),
    ValorForaDoIntervalo { campo: String, min: f64, max: f64 },
}

impl std::fmt::Display for ErroValidacao {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            ErroValidacao::CampoVazio(c) =>
                write!(f, "Campo '{c}' não pode ser vazio"),
            ErroValidacao::TamanhoInvalido { campo, min, max } =>
                write!(f, "Campo '{campo}' deve ter entre {min} e {max} caracteres"),
            ErroValidacao::FormatoInvalido(msg) =>
                write!(f, "Formato inválido: {msg}"),
            ErroValidacao::ValorForaDoIntervalo { campo, min, max } =>
                write!(f, "Campo '{campo}' deve estar entre {min} e {max}"),
        }
    }
}

pub fn validar_nome(nome: &str) -> Result<&str, ErroValidacao> {
    let nome = nome.trim();

    if nome.is_empty() {
        return Err(ErroValidacao::CampoVazio("nome".to_string()));
    }

    if nome.len() < 2 || nome.len() > 100 {
        return Err(ErroValidacao::TamanhoInvalido {
            campo: "nome".to_string(),
            min: 2,
            max: 100,
        });
    }

    if !nome.chars().all(|c| c.is_alphabetic() || c == ' ') {
        return Err(ErroValidacao::FormatoInvalido(
            "nome deve conter apenas letras e espaços".to_string()
        ));
    }

    Ok(nome)
}

pub fn validar_email(email: &str) -> Result<&str, ErroValidacao> {
    let email = email.trim();

    if email.is_empty() {
        return Err(ErroValidacao::CampoVazio("email".to_string()));
    }

    let partes: Vec<&str> = email.split('@').collect();

    if partes.len() != 2 || partes[0].is_empty() || partes[1].is_empty() {
        return Err(ErroValidacao::FormatoInvalido(
            "email deve conter exatamente um '@'".to_string()
        ));
    }

    if !partes[1].contains('.') {
        return Err(ErroValidacao::FormatoInvalido(
            "domínio do email deve conter '.'".to_string()
        ));
    }

    Ok(email)
}

pub fn validar_idade(idade: f64) -> Result<f64, ErroValidacao> {
    if idade < 0.0 || idade > 150.0 {
        return Err(ErroValidacao::ValorForaDoIntervalo {
            campo: "idade".to_string(),
            min: 0.0,
            max: 150.0,
        });
    }
    Ok(idade)
}

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

    // ── Testes de validar_nome ─────────────────────

    #[test]
    fn nome_valido_simples() {
        assert_eq!(validar_nome("Ana"), Ok("Ana"));
    }

    #[test]
    fn nome_valido_com_espaco() {
        assert_eq!(validar_nome("Ana Silva"), Ok("Ana Silva"));
    }

    #[test]
    fn nome_valido_com_espacos_extras() {
        // trim deve remover espaços
        assert_eq!(validar_nome("  Ana  "), Ok("Ana"));
    }

    #[test]
    fn nome_vazio_retorna_erro() {
        assert_eq!(
            validar_nome(""),
            Err(ErroValidacao::CampoVazio("nome".to_string()))
        );
    }

    #[test]
    fn nome_apenas_espacos_retorna_erro() {
        assert_eq!(
            validar_nome("   "),
            Err(ErroValidacao::CampoVazio("nome".to_string()))
        );
    }

    #[test]
    fn nome_muito_curto_retorna_erro() {
        assert_eq!(
            validar_nome("A"),
            Err(ErroValidacao::TamanhoInvalido {
                campo: "nome".to_string(),
                min: 2,
                max: 100,
            })
        );
    }

    #[test]
    fn nome_com_numero_retorna_erro() {
        assert!(matches!(
            validar_nome("Ana2"),
            Err(ErroValidacao::FormatoInvalido(_))
        ));
    }

    // ── Testes de validar_email ────────────────────

    #[test]
    fn email_valido() {
        assert_eq!(
            validar_email("ana@exemplo.com"),
            Ok("ana@exemplo.com")
        );
    }

    #[test]
    fn email_vazio_retorna_erro() {
        assert!(matches!(
            validar_email(""),
            Err(ErroValidacao::CampoVazio(_))
        ));
    }

    #[test]
    fn email_sem_arroba_retorna_erro() {
        assert!(matches!(
            validar_email("anasemdominio"),
            Err(ErroValidacao::FormatoInvalido(_))
        ));
    }

    #[test]
    fn email_sem_dominio_retorna_erro() {
        assert!(matches!(
            validar_email("ana@"),
            Err(ErroValidacao::FormatoInvalido(_))
        ));
    }

    #[test]
    fn email_dominio_sem_ponto_retorna_erro() {
        assert!(matches!(
            validar_email("ana@dominio"),
            Err(ErroValidacao::FormatoInvalido(_))
        ));
    }

    // ── Testes de validar_idade ────────────────────

    #[test]
    fn idade_valida() {
        assert_eq!(validar_idade(25.0), Ok(25.0));
    }

    #[test]
    fn idade_zero_valida() {
        assert_eq!(validar_idade(0.0), Ok(0.0));
    }

    #[test]
    fn idade_maxima_valida() {
        assert_eq!(validar_idade(150.0), Ok(150.0));
    }

    #[test]
    fn idade_negativa_retorna_erro() {
        assert!(matches!(
            validar_idade(-1.0),
            Err(ErroValidacao::ValorForaDoIntervalo { .. })
        ));
    }

    #[test]
    fn idade_acima_maximo_retorna_erro() {
        assert!(matches!(
            validar_idade(151.0),
            Err(ErroValidacao::ValorForaDoIntervalo { .. })
        ));
    }

    // ── Teste de mensagens de erro ─────────────────

    #[test]
    fn mensagem_campo_vazio() {
        let erro = ErroValidacao::CampoVazio("nome".to_string());
        assert_eq!(erro.to_string(), "Campo 'nome' não pode ser vazio");
    }

    #[test]
    fn mensagem_tamanho_invalido() {
        let erro = ErroValidacao::TamanhoInvalido {
            campo: "senha".to_string(),
            min: 8,
            max: 50,
        };
        assert_eq!(
            erro.to_string(),
            "Campo 'senha' deve ter entre 8 e 50 caracteres"
        );
    }
}

Execute com cargo test e veja todos os 18 testes passando:

running 18 tests
test tests::nome_valido_simples ... ok
test tests::nome_valido_com_espaco ... ok
test tests::nome_valido_com_espacos_extras ... ok
test tests::nome_vazio_retorna_erro ... ok
test tests::nome_apenas_espacos_retorna_erro ... ok
test tests::nome_muito_curto_retorna_erro ... ok
test tests::nome_com_numero_retorna_erro ... ok
test tests::email_valido ... ok
test tests::email_vazio_retorna_erro ... ok
test tests::email_sem_arroba_retorna_erro ... ok
test tests::email_sem_dominio_retorna_erro ... ok
test tests::email_dominio_sem_ponto_retorna_erro ... ok
test tests::idade_valida ... ok
test tests::idade_zero_valida ... ok
test tests::idade_maxima_valida ... ok
test tests::idade_negativa_retorna_erro ... ok
test tests::idade_acima_maximo_retorna_erro ... ok
test tests::mensagem_campo_vazio ... ok
test tests::mensagem_tamanho_invalido ... ok

test result: ok. 19 passed; 0 failed

Boas práticas de testes em Rust

Com o tempo, alguns padrões se mostram consistentemente úteis:

Nomeie testes como especificações. Um bom nome de teste lê como uma frase: nome_vazio_retorna_erro, divisao_por_zero_retorna_none. Quando o teste falha, o nome já diz o que aconteceu.

Teste os casos de borda. Valores zero, listas vazias, strings vazias, valores no limite do intervalo — esses são os casos onde bugs se escondem. Cubra-os explicitamente.

Um conceito por teste. Cada teste deve verificar uma única coisa. Se um teste falha, você sabe exatamente o que quebrou. Testes que verificam muitas coisas ao mesmo tempo são difíceis de diagnosticar.

Use matches! para verificar variantes sem comparar dados. Quando você quer saber se um Result é Err de um tipo específico, mas não se importa com o valor exato do erro, matches! é mais limpo que um match completo.

Teste o comportamento, não a implementação. Bons testes verificam o que a função faz, não como ela faz. Isso permite refatorar a implementação sem precisar reescrever os testes.


Fontes e leituras recomendadas

  • The Rust Programming Language, Cap. 11Writing Automated Tests — https://doc.rust-lang.org/book/ch11-00-testing.html
  • Rust by Example — Testing — https://doc.rust-lang.org/rust-by-example/testing.html
  • The Cargo Book — Tests — https://doc.rust-lang.org/cargo/commands/cargo-test.html
  • "Arrange, Act, Assert" — padrão clássico de estruturação de testes — https://wiki.c2.com/?ArrangeActAssert
  • proptest crate — property-based testing em Rust — https://docs.rs/proptest
  • mockall crate — mocking em Rust — https://docs.rs/mockall
  • Rustlings, seção tests — https://github.com/rust-lang/rustlings
Comentários

Mais em Rust

Ownership — A Ideia que Muda Tudo
Ownership — A Ideia que Muda Tudo

Chegamos ao artigo mais importante da s&eacute;rie. Tudo que aprendemos at&ea...

Iteradores e Closures — O Estilo Funcional de Rust
Iteradores e Closures — O Estilo Funcional de Rust

&nbsp; Nos artigos anteriores usamos&nbsp;for para percorrer cole&ccedil;&ot...

Traits — Definindo Comportamento Compartilhado
Traits — Definindo Comportamento Compartilhado

&nbsp; Nos artigos anteriores criamos structs e enums para modelar dados. Ma...