Provando que Funciona — Testes Automatizados com Catch2

Provando que Funciona — Testes Automatizados com Catch2

Rodar e olhar a saída não protege contra regressão, porque ninguém repete a conferência a cada alteração. O texto trata do que vale testar — casos normais, extremos e de erro —, de como verificar exceções com REQUIRE_THROWS_AS e de integrar a suíte ao CMake, sem prometer que teste elimina bug.
Linguagem C++

12 min de leitura

No artigo Redes de Segurança Automáticas — Sanitizers e clang-tidy ganhamos sanitizers e clang-tidy, e vimos que os sanitizers só pegam bugs nos caminhos que os testes efetivamente exercitam. Isso apontou diretamente para a peça que faltava na tríade da qualidade: os testes automatizados. Até aqui, você "testou" seus programas rodando-os e olhando a saída — um método frágil, manual e que não se repete. Testes automatizados são código que verifica, sozinho e a cada execução, que suas funções fazem o que devem. Mais que isso: eles pegam regressões — bugs reintroduzidos quando você muda algo que funcionava. Hoje aprendemos a escrever testes com o Catch2, um framework moderno e leve, e a integrá-los ao CMake. Ao terminar, você fecha a tríade — build organizado (Do Comando Único ao Projeto Real), detecção de erros (Redes de Segurança Automáticas) e verificação de correção (Provando que Funciona) — que sustenta todo software C++ profissional.

Por que testes automatizados

Suponha que você escreveu uma função soma e a testou imprimindo o resultado. Funciona. Meses depois, você "otimiza" algo próximo e, sem perceber, quebra a soma. Sem um teste automatizado, o bug passa despercebido até um usuário reclamar. Com um teste, ele é pego na hora, na sua máquina, antes de sair. Essa é a essência: testes são uma rede de segurança contra o futuro — contra as suas próprias mudanças posteriores. Eles também documentam o comportamento esperado (um teste diz "esta função, com estas entradas, deve dar este resultado") e dão coragem para refatorar, porque você sabe imediatamente se quebrou algo.

O Catch2 torna escrever testes quase trivial. Um teste é um bloco TEST_CASE com asserções REQUIRE que verificam condições:

// testes.cpp — usando Catch2 (versão 3.x)
#include <catch2/catch_test_macros.hpp>

// A função que queremos testar (normalmente viria de um cabeçalho do projeto).
int soma(int a, int b) { return a + b; }

TEST_CASE("soma de dois positivos") {
    REQUIRE(soma(2, 3) == 5);       // se falhar, o teste falha e reporta a linha
    REQUIRE(soma(10, 20) == 30);
}

TEST_CASE("soma com zero e negativos") {
    REQUIRE(soma(0, 0) == 0);
    REQUIRE(soma(-5, 5) == 0);
    REQUIRE(soma(-3, -4) == -7);
}

Cada TEST_CASE é um cenário nomeado; cada REQUIRE é uma verificação. Se soma(2, 3) não der 5, o Catch2 reporta exatamente qual asserção falhou, em qual linha, e quais foram os valores — muito mais útil que olhar uma saída à mão. E você não escreve main: o Catch2 fornece um, que roda todos os TEST_CASE e reporta um resumo (quantos passaram, quantos falharam).

O que testar: casos normais, extremos e de erro

Escrever testes bem é uma habilidade, e o princípio central é cobrir três tipos de caso. Os casos normais (o comportamento típico), os casos extremos (limites: zero, vazio, o maior valor possível, o primeiro e o último elemento) e os casos de erro (entradas inválidas, que devem falhar de forma controlada). Veja testando uma função mais interessante — a inversão de string do artigo O Fim do char — Textos de Verdade com std::string*:

#include <catch2/catch_test_macros.hpp>
#include <string>

std::string invertida(const std::string& s) {
    std::string r;
    for (std::size_t i = s.size(); i > 0; --i) r += s[i - 1];
    return r;
}

TEST_CASE("inversão de string") {
    // Caso normal:
    REQUIRE(invertida("mundo") == "odnum");

    // Casos extremos: string vazia e um único caractere.
    REQUIRE(invertida("") == "");          // vazio: não deve quebrar
    REQUIRE(invertida("a") == "a");        // um char: igual a si mesmo

    // Caso interessante: palíndromo (invertido é igual ao original).
    REQUIRE(invertida("arara") == "arara");
}

TEST_CASE("inversão é reversível") {
    // Uma propriedade: inverter duas vezes volta ao original.
    std::string original = "programacao";
    REQUIRE(invertida(invertida(original)) == original);
}

Note o cuidado com os casos extremos: a string vazia e a de um caractere são exatamente onde bugs costumam se esconder (um laço que acessa s[i-1] com i=0 estouraria). Testá-los explicitamente garante robustez. O segundo TEST_CASE testa uma propriedade — "inverter duas vezes volta ao original" — uma forma poderosa de teste que não depende de você calcular o resultado esperado à mão.

Testando exceções e integrando ao CMake

O Catch2 também verifica que erros acontecem quando devem. Lembra da Pilha que lançava std::out_of_range ao desempilhar vazia (artigo Estruturas de Dados para Qualquer Tipo — Templates de Classe)? Você testa isso com REQUIRE_THROWS:

#include <catch2/catch_test_macros.hpp>
#include <stdexcept>

int raiz_inteira(int n) {
    if (n < 0) throw std::invalid_argument("negativo");
    int r = 0;
    while ((r + 1) * (r + 1) <= n) ++r;
    return r;
}

TEST_CASE("raiz inteira: casos válidos") {
    REQUIRE(raiz_inteira(0) == 0);
    REQUIRE(raiz_inteira(9) == 3);
    REQUIRE(raiz_inteira(15) == 3);   // piso da raiz
    REQUIRE(raiz_inteira(16) == 4);
}

TEST_CASE("raiz inteira: entrada inválida lança") {
    // Verifica que a exceção CERTA é lançada para entrada negativa.
    REQUIRE_THROWS_AS(raiz_inteira(-1), std::invalid_argument);
}

REQUIRE_THROWS_AS verifica que a chamada lança a exceção do tipo esperado — testar o comportamento de erro é tão importante quanto testar o sucesso. Para rodar os testes, você os integra ao CMake como um executável ligado à biblioteca do Catch2 (obtida, por exemplo, via FetchContent, que o CMake baixa automaticamente):

# Trecho do CMakeLists.txt para testes
include(FetchContent)
FetchContent_Declare(Catch2
    GIT_REPOSITORY https://github.com/catchorg/Catch2.git
    GIT_TAG v3.5.0)
FetchContent_MakeAvailable(Catch2)

add_executable(testes testes.cpp)
target_link_libraries(testes PRIVATE Catch2::Catch2WithMain)   # Catch2 fornece o main

# Registra os testes para rodar com 'ctest'
enable_testing()
add_test(NAME todos_os_testes COMMAND testes)

Com isso, cmake --build build compila os testes e ctest --test-dir build os roda, reportando o que passou e o que falhou. Rodar os testes a cada mudança — idealmente de forma automática — é o que mantém a qualidade ao longo do tempo.

A honestidade sobre testes

Prometo a medida certa. Testes são essenciais, mas há equívocos a evitar. Primeiro, testar tudo cegamente é desperdício — priorize a lógica que importa (regras de negócio, algoritmos, casos extremos), não getters triviais. Segundo, testes dão confiança, não garantia: eles provam que os casos que você pensou funcionam, não que não há bugs — um caso que você não imaginou pode escapar. Terceiro, testes têm custo de manutenção: quando o comportamento muda legitimamente, os testes precisam mudar junto, e testes frágeis demais (que quebram a qualquer refatoração inofensiva) mais atrapalham que ajudam. A diretriz madura: escreva testes para o que tem risco de quebrar e importa se quebrar, cubra os casos extremos onde bugs se escondem, e trate a suíte de testes como código de primeira classe — mantida, legível, confiável. Testar bem é um investimento que se paga muitas vezes; testar mal é cerimônia.

Teste automatizado não prova ausência de bug; ele congela o comportamento que já funciona, para que uma mudança futura em outro lugar não o quebre em silêncio. É essa proteção contra regressão que "rodei e olhei a saída" não oferece, porque ninguém repete a conferência manual a cada alteração. O maior valor costuma estar nos casos extremos — vazio, um elemento, valor no limite — e no caminho de erro, que é justamente o menos exercitado em uso normal.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma função int maximo(const std::vector<int>& v) que devolva o maior elemento (assuma vetor não-vazio) e três TEST_CASE do Catch2: um caso normal, um com todos os elementos iguais, e um com um único elemento.

Ver resposta

✓ Resposta: Função e testes:

#include <catch2/catch_test_macros.hpp>
#include <vector>
#include <algorithm>

int maximo(const std::vector<int>& v) {
    return *std::max_element(v.begin(), v.end());
}

TEST_CASE("máximo: caso normal") {
    REQUIRE(maximo({3, 7, 2, 9, 1}) == 9);
}
TEST_CASE("máximo: todos iguais") {
    REQUIRE(maximo({5, 5, 5}) == 5);
}
TEST_CASE("máximo: um elemento") {
    REQUIRE(maximo({42}) == 42);
}

Exercício 2

Explique o conceito de regressão e como testes automatizados a previnem. Por que "testei rodando e olhando a saída" não protege contra regressões?

Ver resposta

✓ Resposta: Uma regressão é um bug reintroduzido num código que antes funcionava — normalmente causado por uma mudança feita em outro lugar que, sem querer, quebra um comportamento que já estava correto. Testes automatizados a previnem porque, ao rodá-los após cada mudança, qualquer comportamento que deixe de funcionar faz um teste falhar imediatamente, apontando o que quebrou. "Testar rodando e olhando a saída" não protege contra regressões porque é manual e não se repete: você olhou a saída uma vez, meses atrás, e não vai reexecutar e reinspecionar visualmente todos os casos a cada pequena mudança — é inviável lembrar de tudo e tedioso demais. O teste automatizado, ao contrário, reverifica todos os casos instantaneamente e sem esforço, sempre que você quiser, pegando a regressão no momento em que ela é introduzida.

Exercício 3

Para a função invertida da aula, proponha dois casos extremos além dos mostrados que valeria a pena testar, justificando por que cada um é um bom candidato a esconder bugs.

Ver resposta

✓ Resposta: Dois casos extremos adicionais valiosos para invertida: primeiro, uma string com caracteres repetidos e espaços (como "a b a"), que testa se o laço preserva corretamente todos os caracteres, incluindo espaços, na ordem certa — bugs de índice às vezes pulam ou duplicam elementos, e uma string com estrutura visível expõe isso. Segundo, uma string longa (dezenas ou centenas de caracteres), que testa se não há problema de desempenho grosseiro nem estouro em tamanhos maiores, e confirma que a lógica escala além dos casos minúsculos. Ambos são bons candidatos porque exercitam o laço em condições que os exemplos curtos ("mundo") não cobrem: o primeiro verifica preservação fiel de cada posição, o segundo verifica robustez em escala.

Exercício 4

Escreva um teste com REQUIRE_THROWS_AS para uma função dividir(int a, int b) que lança std::domain_error quando b == 0. Explique por que testar o comportamento de erro é tão importante quanto testar o sucesso.

Ver resposta

✓ Resposta: Teste de exceção:

#include <catch2/catch_test_macros.hpp>
#include <stdexcept>

int dividir(int a, int b) {
    if (b == 0) throw std::domain_error("divisão por zero");
    return a / b;
}

TEST_CASE("dividir: sucesso") {
    REQUIRE(dividir(10, 2) == 5);
}
TEST_CASE("dividir: divisor zero lança domain_error") {
    REQUIRE_THROWS_AS(dividir(1, 0), std::domain_error);
}

Testar o comportamento de erro é tão importante quanto testar o sucesso porque o tratamento de erros é parte do contrato da função: dividir promete lançar std::domain_error para b == 0, e código que depende dela conta com esse comportamento. Se, numa refatoração, alguém remover a checagem (fazendo a divisão por zero causar comportamento indefinido em vez da exceção), sem um teste isso passaria despercebido até causar um desastre. O REQUIRE_THROWS_AS verifica que a exceção certa é lançada, garantindo que o caminho de erro — frequentemente o menos exercitado no uso casual — permaneça correto.

Exercício 5

Discuta a afirmação "testes dão confiança, não garantia". Dê um exemplo de bug que uma suíte de testes razoável poderia deixar passar, e explique como você reduziria (sem eliminar) esse risco.

Ver resposta

✓ Resposta: A afirmação reconhece que uma suíte de testes prova que os cenários que você escreveu funcionam, mas não que não existam bugs — só testa o que foi imaginado. Exemplo de bug que poderia passar: uma função media que calcula corretamente para vetores normais (testados) mas sofre um overflow de inteiros ao somar muitos valores grandes — se nenhum teste usou valores grandes o suficiente para estourar, o bug escapa, mesmo com a suíte toda verde. Como reduzir (sem eliminar) esse risco: primeiro, cobrir deliberadamente casos extremos e limites (valores máximos, coleções enormes, entradas na fronteira do domínio), que é onde bugs se escondem; segundo, usar testes de propriedade que geram muitas entradas variadas em vez de exemplos fixos; terceiro, combinar os testes com os sanitizers do artigo Redes de Segurança Automáticas — Sanitizers e clang-tidy (rodar a suíte sob ASan/UBSan pegaria o overflow); e quarto, adicionar um teste específico assim que qualquer bug for descoberto em produção, para que aquela classe de erro nunca mais passe. Nenhuma dessas medidas dá garantia absoluta — sempre pode haver um caso não imaginado —, mas juntas elevam muito a confiança, que é o objetivo realista dos testes.

Comentários

Mais em Linguagem C++

Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo
Um Método, Vários Comportamentos — Funções Virtuais e Polimorfismo

Uma função que recebe uma forma qualquer e manda desenhá-la, sem saber se é…

Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake
Imagem e Som — Recursos de Jogo com RAII, e o Início do Snake

Textura e som entram na mesma lista de arquivo, memória e conexão: alguém…

O Destino e o Novo Começo — Encerrando a Jornada Completa
O Destino e o Novo Começo — Encerrando a Jornada Completa

O fim de cinquenta e nove artigos, e o balanço do que mudou: não a lista de…