Testes Automatizados em C

[417] Testes Automatizados em C

Testar só o caminho feliz dá segurança falsa: o bug mora no zero, no negativo, no vetor vazio e nos dois valores iguais. A aula monta uma suíte com assert e funções de teste, explica o NDEBUG que apaga tudo na compilação final, e amarra os testes ao make e aos sanitizers.
Linguagem C

12 min de leitura

Fechamos a Fase 6 com uma prática que distingue código profissional de código amador: os testes automatizados. Até agora, você verificava se seu código funcionava rodando-o e olhando a saída — teste manual, que é lento, entediante e fácil de esquecer. Testes automatizados invertem isso: você escreve código que verifica seu código, e uma única execução confirma, em segundos, se tudo continua funcionando. É a rede de segurança que permite modificar um programa sem medo, sabendo na hora se algo quebrou. Combinada com as ferramentas das aulas anteriores, essa prática eleva a qualidade e a confiança do seu software a outro patamar.

Por que testar automaticamente

Suponha que você escreveu uma função de ordenação e a testou manualmente com alguns vetores. Funciona. Semanas depois, você a otimiza — e sem querer introduz um bug que quebra a ordenação de vetores com elementos repetidos. Sem testes automatizados, você só descobriria isso quando o programa falhasse em produção, longe da mudança que causou o problema. Com testes automatizados, você rodaria a suíte de testes após a mudança e veria imediatamente que um caso quebrou. Essa é a essência do valor: testes transformam "espero que ainda funcione" em "sei que funciona", e localizam regressões no instante em que surgem, não meses depois.

O conceito básico: a asserção

No coração de todo teste está a ideia de asserção — uma afirmação sobre o que o código deveria fazer, que é verificada automaticamente. "A função somar(2, 3) deve retornar 5" é uma asserção. Se ela for verdadeira, o teste passa; se falsa, o teste falha e você é avisado. O C oferece uma ferramenta básica embutida para isso: a macro assert, de <assert.h>:

#include <assert.h>
#include <stdio.h>

int somar(int a, int b) {
    return a + b;
}

int main(void) {
    assert(somar(2, 3) == 5);   // passa: nada acontece
    assert(somar(-1, 1) == 0);  // passa
    assert(somar(0, 0) == 0);   // passa

    printf("Todos os testes passaram!\n");

    // assert(somar(2, 2) == 5); // se descomentar, o programa ABORTA aqui
    return 0;
}

A macro assert(condicao) verifica se a condição é verdadeira. Se for, o programa continua silenciosamente. Se for falsa, assert interrompe o programa imediatamente, imprimindo qual asserção falhou, em que arquivo e linha. É simples e direto: você afirma o que espera, e o programa reclama se a realidade discordar. Um detalhe útil: as asserções podem ser desativadas na compilação final definindo a macro NDEBUG (gcc -DNDEBUG ...), fazendo assert não gerar código nenhum — assim os testes rodam durante o desenvolvimento sem pesar no programa de produção.

Escrevendo funções de teste organizadas

Para ir além de asserções soltas, organizamos os testes em funções de teste, cada uma verificando um aspecto do código. Isso torna a suíte legível e permite ver o que está sendo testado:

#include <assert.h>
#include <stdio.h>

// funções sob teste
int maximo(int a, int b) {
    return (a > b) ? a : b;
}

int eh_par(int n) {
    return n % 2 == 0;
}

// funções de teste
void testar_maximo(void) {
    assert(maximo(5, 3) == 5);
    assert(maximo(3, 5) == 5);
    assert(maximo(4, 4) == 4);   // iguais
    assert(maximo(-1, -5) == -1); // negativos
    printf("  [OK] testar_maximo\n");
}

void testar_eh_par(void) {
    assert(eh_par(4) == 1);
    assert(eh_par(7) == 0);
    assert(eh_par(0) == 1);   // zero é par
    assert(eh_par(-2) == 1);  // negativo par
    printf("  [OK] testar_eh_par\n");
}

int main(void) {
    printf("Rodando testes...\n");
    testar_maximo();
    testar_eh_par();
    printf("Todos os testes passaram!\n");
    return 0;
}

Cada função de teste agrupa asserções relacionadas e, ao final, imprime uma confirmação de que passou. O main funciona como um "executor" que roda todas as funções de teste em sequência. Se qualquer asserção falhar, o programa aborta ali, apontando exatamente qual teste e qual condição quebrou. Essa organização — funções de teste focadas, um executor que as chama — é a estrutura básica de qualquer suíte de testes, e escala bem à medida que você adiciona mais casos.

Testando os casos que importam: as bordas

Um bom conjunto de testes não verifica apenas o caso comum — ele foca especialmente nos casos de borda (edge cases), onde os bugs se escondem. Ao testar uma função, pergunte-se: o que acontece com zero? Com valores negativos? Com o vetor vazio? Com o maior e o menor valor possível? Com entradas inválidas? Repare que, nos testes acima, incluí propositalmente maximo(4, 4) (valores iguais), eh_par(0) (zero) e eh_par(-2) (negativo) — justamente os casos que uma implementação apressada poderia errar. Testar bordas é onde os testes automatizados mais rendem, porque esses são exatamente os cenários que você tende a esquecer ao verificar manualmente. Um teste que só cobre o "caminho feliz" dá uma falsa sensação de segurança.

Bibliotecas de teste profissionais

A macro assert é ótima para começar e suficiente para muitos casos, mas tem limitações: ela aborta no primeiro erro (você não vê todos os testes que falharam de uma vez), e não oferece relatórios sofisticados. Para projetos sérios, existem frameworks de teste dedicados ao C, como o Unity, o CMocka e o Criterion. Eles oferecem recursos que a assert crua não tem: rodar todos os testes mesmo que alguns falhem (mostrando um resumo do que passou e do que quebrou), asserções mais expressivas (comparar strings, floats com tolerância, ponteiros), agrupamento de testes em suítes, e integração com sistemas de build e integração contínua. A ideia central é a mesma — asserções organizadas em funções de teste —, mas com infraestrutura mais robusta em volta. Para o aprendizado e projetos pequenos, assert basta perfeitamente; ao crescer, vale adotar um desses frameworks.

Integrando testes ao fluxo de trabalho

O verdadeiro poder dos testes surge quando eles se tornam parte do seu processo, conectando-se a tudo que construímos na Fase 6. Você adiciona um alvo de testes ao seu Makefile (aula Automação de Build com Make) ou CMake (aula Projetos Maiores com CMake), para rodar toda a suíte com um único comando (make test). Roda os testes sob os sanitizers (aula Caçando Bugs de Memória com Valgrind e Sanitizers), para que qualquer erro de memória durante um teste faça o teste falhar — combinando verificação de correção com verificação de memória. E, idealmente, roda os testes automaticamente a cada mudança, seja localmente ou num sistema de integração contínua. Assim, os testes deixam de ser um esforço pontual e viram uma vigilância contínua: cada alteração no código é imediatamente validada contra todo o comportamento esperado. É essa integração que transforma testes de "algo que você às vezes faz" em "a rede que sempre te protege".

Fechando a Fase 6

Com os testes automatizados, encerramos a fase de ferramentas profissionais. Você entrou nela sabendo escrever código C; sai dela sabendo trabalhar com ele como um profissional: depurar com o GDB, caçar bugs de memória com Valgrind e sanitizers, automatizar builds com Make e CMake, reconhecer e evitar comportamento indefinido, tratar erros de forma idiomática, e agora garantir a correção com testes automatizados. Essas ferramentas são o que separa escrever código que "parece funcionar" de entregar software confiável. Na próxima fase, voltamos nossa atenção para a qualidade do próprio código — começando pelo código limpo em C: como escolher nomes, estruturar funções e organizar seu trabalho para que ele seja legível, mantível e um prazer de ler, tanto para os outros quanto para o seu "eu" do futuro.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma função int fatorial(int n) e uma função de teste testar_fatorial que use assert para verificar pelo menos quatro casos, incluindo o caso de borda fatorial(0) (que deve ser 1). Rode e confirme que passa.

Ver resposta

✓ Resposta:

#include <assert.h>
#include <stdio.h>

int fatorial(int n) {
    int r = 1;
    for (int i = 2; i <= n; i++) r *= i;
    return r;
}

void testar_fatorial(void) {
    assert(fatorial(0) == 1);   // caso de borda: 0! = 1
    assert(fatorial(1) == 1);
    assert(fatorial(5) == 120);
    assert(fatorial(3) == 6);
    printf("[OK] testar_fatorial\n");
}

int main(void) {
    testar_fatorial();
    printf("Todos os testes passaram!\n");
    return 0;
}

O caso fatorial(0) == 1 é o de borda mais importante — uma implementação que começasse o produto errado poderia falhar exatamente nele.

Exercício 2

Escreva uma função int contar_vogais(const char *s) que conte as vogais numa string, e uma função de teste que verifique casos variados: uma string normal, uma string sem vogais, uma string vazia e uma string só de vogais.

Ver resposta

✓ Resposta:

#include <assert.h>
#include <stdio.h>
#include <ctype.h>

int contar_vogais(const char *s) {
    int c = 0;
    for (int i = 0; s[i] != '\0'; i++) {
        char x = tolower((unsigned char)s[i]);
        if (x=='a'||x=='e'||x=='i'||x=='o'||x=='u') c++;
    }
    return c;
}

void testar_contar_vogais(void) {
    assert(contar_vogais("programacao") == 5); // o,a,a,o... contando: a,a,o,a,o = 5
    assert(contar_vogais("xyz") == 0);          // sem vogais
    assert(contar_vogais("") == 0);             // string vazia (borda)
    assert(contar_vogais("aeiou") == 5);        // só vogais
    printf("[OK] testar_contar_vogais\n");
}

int main(void) {
    testar_contar_vogais();
    printf("Todos os testes passaram!\n");
    return 0;
}

Os casos de borda (string vazia, sem vogais, só vogais) garantem que a função lida corretamente com os extremos.

Exercício 3

Propositalmente introduza um bug numa das suas funções (por exemplo, faça fatorial retornar 0 para n == 0) e rode os testes. Descreva o que a macro assert faz e qual informação ela fornece quando a asserção falha.

Ver resposta

✓ Resposta: Se você alterar fatorial para retornar 0 quando n == 0 (introduzindo o bug) e rodar os testes, a macro assert(fatorial(0) == 1) falhará. O que assert faz: ela avalia a condição; como fatorial(0) agora retorna 0, a condição 0 == 1 é falsa, então assert interrompe o programa imediatamente (chama abort) e imprime uma mensagem no stderr informando qual asserção falhou, com o texto exato da condição, o nome do arquivo e o número da linha — algo como Assertion 'fatorial(0) == 1' failed seguido do local. Essa informação diz precisamente qual expectativa foi violada e onde, permitindo localizar o bug de imediato. É a diferença entre "algo está errado em algum lugar" e "esta condição específica, nesta linha, não é satisfeita".

Exercício 4

Explique por que testar casos de borda (zero, valores negativos, vetores vazios, limites) é especialmente importante. Dê um exemplo de um bug que só apareceria num caso de borda e passaria despercebido num teste do "caminho feliz".

Ver resposta

✓ Resposta: Testar casos de borda é especialmente importante porque os bugs tendem a se esconder justamente nos extremos e nas transições — onde a lógica muda de comportamento. O caso comum (o "caminho feliz") geralmente funciona porque foi o cenário que o programador tinha em mente ao escrever o código; os erros surgem nos casos que ele não considerou: o zero, o vazio, o negativo, o limite máximo, o primeiro e o último elemento. Exemplo de bug que só apareceria numa borda: uma função media(int *v, int n) que calcula soma / n funcionaria perfeitamente para qualquer vetor não vazio (caminho feliz), mas causaria uma divisão por zero se chamada com n == 0 (vetor vazio) — um caso de borda. Um teste que só verificasse media com vetores de 3, 5 ou 10 elementos passaria sempre, dando falsa confiança, enquanto o programa quebraria em produção no primeiro vetor vazio. Testar a borda n == 0 revelaria o bug imediatamente. Bugs de "um a mais" (acessar v[n] em vez de parar em v[n-1]), estouros nos valores máximos, e tratamento de entradas nulas são todos exemplos clássicos que só aparecem nas bordas.

Exercício 5

Explique como os testes automatizados se conectam às outras ferramentas da Fase 6. Descreva um fluxo de trabalho que combine testes com sanitizers (aula Caçando Bugs de Memória com Valgrind e Sanitizers) e automação de build (aula Automação de Build com Make ou Projetos Maiores com CMake), e explique o benefício dessa combinação.

Ver resposta

✓ Resposta: Os testes automatizados se conectam às outras ferramentas da Fase 6 formando um sistema integrado de garantia de qualidade. Com automação de build (Make/CMake): você adiciona um alvo de testes (por exemplo, make test) que compila e roda toda a suíte de testes com um único comando, tornando trivial verificar o código a qualquer momento. Com sanitizers (aula Caçando Bugs de Memória com Valgrind e Sanitizers): você compila os testes com -fsanitize=address,undefined, de modo que, além de verificar a correção (as asserções), cada execução de teste também vigia a memória — se um teste, ao rodar, causar um vazamento, um acesso inválido ou comportamento indefinido, o sanitizer o flagra e faz o teste falhar. Fluxo combinado: um alvo make test que compila a suíte com sanitizers ligados e a executa; idealmente, esse alvo roda automaticamente a cada mudança (localmente ou em integração contínua). Benefício: essa combinação verifica, de uma só vez e automaticamente, duas dimensões independentes de qualidade — que o código produz os resultados corretos (testes) e que o faz sem erros de memória (sanitizers) — a cada alteração. Você ganha confiança tripla: uma mudança que quebre a lógica falha uma asserção; uma mudança que introduza um bug de memória falha sob o sanitizer; e tudo isso acontece com um único comando, imediatamente, em vez de meses depois em produção. É a materialização da rede de segurança que permite evoluir o código sem medo.

Comentários

Mais em Linguagem C

Tabelas Hash: Busca em Tempo Constante
Tabelas Hash: Busca em Tempo Constante

Em vez de procurar onde o dado está, calcula-se onde ele deveria estar — e é…

Recursão: Quando uma Função Chama a Si Mesma
Recursão: Quando uma Função Chama a Si Mesma

O fatorial recursivo é bonito; o Fibonacci recursivo é uma armadilha, porque…

Processos: fork, exec e wait
Processos: fork, exec e wait

A linha do fork é executada uma vez e retorna duas — e é do valor diferente em…