Nesta aula, vamos explorar dois tipos de testes essenciais no ecossistema Rust: os testes de integração e os doc tests. Enquanto os testes unitários ficam no mesmo arquivo do código e testam funções internas, os testes de integração são colocados em uma pasta separada e testam o crate como um todo, usando apenas a API pública. Já os doc tests são exemplos de código escritos na documentação que são compilados e executados como testes, garantindo que a documentação esteja sempre correta.

Esses testes são fundamentais para garantir a qualidade e a confiabilidade do seu software. Testes de integração ajudam a validar que os módulos do seu crate funcionam bem juntos, enquanto doc tests garantem que os exemplos fornecidos aos usuários estejam funcionando. Vamos mergulhar nos detalhes e ver como implementá-los na prática.

Pasta tests/

No Rust, testes de integração são arquivos que ficam na pasta tests/ na raiz do seu projeto (ao lado de src/). Cada arquivo dentro dessa pasta é compilado como um crate separado, que depende do seu crate principal. Isso significa que os testes de integração só podem acessar a API pública do seu crate, ou seja, itens marcados como pub. Isso é uma ótima prática porque força você a testar o crate da mesma forma que um usuário externo faria.

Para criar um teste de integração, basta criar um arquivo com extensão .rs dentro de tests/. Por exemplo, se seu crate se chama meu_crate, você pode criar tests/integracao.rs e usar a sintaxe use meu_crate::alguma_funcao; para importar o que deseja testar. Cada função marcada com #[test] será executada como um teste. Para executar, use cargo test, que também executará os testes unitários e doc tests.

// tests/integracao.rs
use meu_crate::soma;

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

É possível ter vários arquivos na pasta tests/, e cada um é um crate separado. Isso ajuda a organizar testes por funcionalidade. Além disso, você pode usar subpastas dentro de tests/ para organizar ainda mais, mas arquivos em subpastas não são compilados como testes automaticamente; eles são módulos que podem ser importados por outros arquivos de teste. Para isso, use a sintaxe mod subpasta; dentro de um arquivo de teste.

Uma vantagem dos testes de integração é que eles podem testar o comportamento de cenários mais complexos, como interações entre diferentes módulos. Por exemplo, se você tem um crate que lida com arquivos, você pode criar testes que criam arquivos temporários e verificam se o comportamento está correto. Isso é mais difícil de fazer em testes unitários porque eles não têm acesso ao crate como um todo.

Doc tests

Doc tests são exemplos de código que você escreve na documentação do seu crate, usando três crases ``` ou quatro com a linguagem especificada. O Rust pode compilar e executar esses exemplos como testes, garantindo que o código documentado esteja correto e que os usuários possam copiar e colar sem problemas. Isso é extremamente útil para bibliotecas, pois a documentação se torna uma fonte de exemplos testados.

Para escrever um doc test, você pode usar um bloco de código em um comentário de documentação (/// ou //!). Por exemplo:

/// Soma dois números.
///
/// # Exemplos
///
/// ```
/// use meu_crate::soma;
/// assert_eq!(soma(2, 3), 5);
/// ```
pub fn soma(a: i32, b: i32) -> i32 {
    a + b
}

Quando você executa cargo test, o Rust compila e executa esse exemplo. Se o código falhar ou não compilar, o teste falhará. Você pode usar a diretiva ```ignore para desabilitar a execução de um exemplo, ou ```no_run para compilar mas não executar. Isso é útil para exemplos que não podem ser executados em todos os ambientes, como código que depende de hardware específico.

Os doc tests também podem ser usados para testar funções privadas? Não, eles são executados como se fossem um usuário externo, portanto só podem acessar itens públicos. Isso é consistente com os testes de integração. Para testar itens privados, você deve usar testes unitários dentro do módulo.

Uma dica: doc tests são uma ótima forma de manter a documentação viva. Se você mudar a implementação de uma função, os exemplos na documentação podem quebrar, o que o alerta para atualizar a documentação. Isso é uma prática recomendada para bibliotecas públicas.

Organização

Organizar testes é crucial para manter um projeto sustentável. Em Rust, temos três níveis de testes: unitários, de integração e doc tests. Cada um tem seu propósito e deve ser usado de forma adequada.

Testes unitários são escritos no mesmo arquivo do código, dentro de um módulo #[cfg(test)]. Eles testam funções individuais e podem acessar código privado. Testes de integração, como vimos, ficam na pasta tests/ e testam o crate como um todo. Doc tests ficam na documentação e testam exemplos.

Para organizar testes de integração, você pode criar vários arquivos na pasta tests/ por funcionalidade. Por exemplo, se você tem um crate de matemática, pode ter tests/soma.rs, tests/multiplicacao.rs, etc. Se houver código comum entre os testes, você pode criar um módulo em tests/common/mod.rs e importá-lo nos arquivos de teste com mod common;. Isso evita duplicação.

Além disso, você pode usar a funcionalidade de testes de integração com subpastas para agrupar testes relacionados. Por exemplo, tests/validacao/ pode conter testes de validação de entrada, e tests/processamento/ testes de processamento. Cada subpasta precisa ter um arquivo mod.rs para ser reconhecida como módulo.

Uma boa prática é manter os testes de integração focados em cenários de uso real, não em detalhes de implementação. Se você precisa testar uma função privada, é melhor escrever um teste unitário. Os testes de integração devem verificar o comportamento externo do crate, como se você fosse um usuário.

Cobertura (visão geral)

Cobertura de código é uma métrica que indica a porcentagem do código que é executada durante os testes. No Rust, existem ferramentas como cargo-tarpaulin que podem calcular a cobertura. Embora cobertura não seja sinônimo de qualidade, ela ajuda a identificar partes do código que não estão sendo testadas.

Para medir a cobertura, você pode instalar a ferramenta com cargo install cargo-tarpaulin e executar cargo tarpaulin. Isso gerará um relatório mostrando a porcentagem de linhas cobertas e quais linhas não foram executadas. Você pode usar isso para adicionar testes para os trechos não cobertos.

É importante lembrar que 100% de cobertura não significa que seu código está livre de bugs, mas ajuda a reduzir a chance de erros não detectados. Uma boa prática é focar em testar os caminhos críticos e os casos de borda, em vez de buscar cegamente uma alta cobertura.

No contexto de testes de integração, a cobertura pode ser calculada incluindo os testes de integração. O cargo-tarpaulin, por padrão, executa todos os testes (unitários, integração e doc tests) e calcula a cobertura sobre o código-fonte. Você pode configurar para ignorar certos arquivos ou funções se necessário.

Boas práticas e observações finais

Ao escrever testes de integração, procure testar o comportamento do crate de forma realista. Use estruturas de dados e cenários que um usuário encontraria. Evite depender de detalhes internos. Mantenha os testes organizados e legíveis, com nomes descritivos.

Para doc tests, lembre-se de que eles são parte da documentação. Escreva exemplos que ajudem o usuário a entender a API, não apenas testes. Use # Exemplos para separar seções, e inclua comentários explicativos quando necessário.

Por fim, integre os testes ao seu fluxo de desenvolvimento. Execute cargo test com frequência e, se possível, configure um pipeline de CI para rodar os testes automaticamente a cada alteração. Isso garante que seu código esteja sempre funcionando e documentado corretamente.

Referências

Exercícios

  1. Crie um novo projeto binário chamado calculadora que tenha uma função pública soma(a: i32, b: i32) -> i32. Escreva um teste de integração na pasta tests/ que verifique se soma(2, 2) retorna 4.

    ✓ Resposta: Crie o projeto com cargo new calculadora. No arquivo src/lib.rs, defina a função pub fn soma(a: i32, b: i32) -> i32 { a + b }. Depois, crie o arquivo tests/integracao.rs com o conteúdo:
    use calculadora::soma;
    
    #[test]
    fn test_soma() {
        assert_eq!(soma(2, 2), 4);
    }
    
    Execute cargo test.
  2. Adicione um doc test à função soma do exercício anterior, mostrando um exemplo de uso. O doc test deve verificar que soma(5, 7) retorna 12.

    ✓ Resposta: Modifique a documentação da função soma em src/lib.rs para incluir o exemplo:
    /// Soma dois números.
    ///
    /// # Exemplos
    ///
    /// ```
    /// use calculadora::soma;
    /// assert_eq!(soma(5, 7), 12);
    /// ```
    pub fn soma(a: i32, b: i32) -> i32 {
        a + b
    }
    
    Execute cargo test para verificar que o doc test passa.
  3. Explique a diferença entre testes unitários e testes de integração em Rust, e dê um exemplo de situação onde cada um é mais adequado.

    ✓ Resposta: Testes unitários são escritos no mesmo arquivo do código e podem acessar funções privadas. São adequados para testar lógica interna de uma função ou módulo isoladamente. Testes de integração ficam na pasta tests/ e testam o crate como um todo, usando apenas a API pública. São adequados para verificar se os módulos interagem corretamente e se o comportamento externo está correto. Por exemplo, um teste unitário pode verificar uma função auxiliar privada, enquanto um teste de integração verifica que uma função pública retorna o resultado esperado quando chamada com argumentos válidos.
  4. Como você pode organizar testes de integração para um crate que tem várias funcionalidades? Descreva uma estrutura de pastas e arquivos.

    ✓ Resposta: Você pode criar arquivos separados para cada funcionalidade, por exemplo: tests/soma.rs, tests/subtracao.rs, etc. Se houver código comum, crie um módulo em tests/common/mod.rs e importe com mod common; em cada arquivo. Também pode usar subpastas, como tests/operacoes/soma.rs e tests/operacoes/mod.rs, para agrupar testes relacionados. Cada subpasta precisa de um mod.rs para ser tratada como módulo.
  5. Qual é a finalidade da diretiva no_run em um doc test? Dê um exemplo de quando usá-la.

    ✓ Resposta: A diretiva no_run indica que o código deve ser compilado, mas não executado. Isso é útil para exemplos que requerem um ambiente externo (como acesso a rede, hardware) ou que são demorados, mas que ainda devem ser verificados quanto à compilação. Por exemplo:
    /// ```no_run
    /// let url = "https://example.com";
    /// let resposta = reqwest::blocking::get(url);
    /// ```
    pub fn exemplo() {}
    
    Sem no_run, o teste tentaria fazer uma requisição de rede, o que pode falhar em ambientes offline.