A Pedra de Acabamento — Integração, Testes e Verificação do Sistema

A Pedra de Acabamento — Integração, Testes e Verificação do Sistema

Peças que funcionam isoladas ainda podem não se encaixar, e é para isso que serve o teste de integração. O artigo monta a suíte que exercita a cadeia inteira, roda o projeto sob os sanitizers e revisa o código contra as Core Guidelines — incluindo o teste de que uma falha não deixa a tabela inconsistente.
Linguagem C++

13 min de leitura

Nos artigos A Primeira Pedra — Uma Célula Poliforme com std::variant a A Quarta Pedra — Tratamento de Erros Coerente com std::expected construímos as quatro pedras do nosso mini banco de dados: o Value poliforme, as linhas e tabelas com integridade, o motor de consultas, e o tratamento de erros coerente. Cada peça foi testada isoladamente. Hoje fazemos o acabamento — o trabalho menos glamouroso e mais profissional de todos: garantir que as peças funcionam juntas, que o sistema inteiro é robusto, e que ele passa pelo mesmo escrutínio de qualidade que exigiríamos de software real. Vamos montar um executável de demonstração polido, escrever testes de integração que exercitam o fluxo completo, rodar tudo sob os sanitizers da Fase 8 para caçar bugs de memória, e revisar o código à luz das Core Guidelines. É a diferença entre "as peças funcionam" e "o sistema é confiável" — e é a última pedra antes da retrospectiva final.

Testes de integração: as peças trabalhando juntas

Testes de unidade (que fizemos) verificam cada peça isolada; testes de integração verificam que elas colaboram corretamente no fluxo real. Escrevemos um teste que exercita a jornada completa — criar tabela, inserir, consultar, tratar erro — como um usuário faria:

// tests/test_integracao.cpp
#include <catch2/catch_test_macros.hpp>
#include "minidb/table.h"
#include "minidb/query.h"
using namespace minidb;

// Auxiliar para montar uma linha rapidamente nos testes.
static Row usuario(const std::string& nome, int64_t idade) {
    Row r; r.set("nome", nome); r.set("idade", idade); return r;
}

TEST_CASE("fluxo completo: inserir, filtrar e ordenar") {
    Table usuarios({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});

    // Insere vários registros, verificando que cada inserção teve sucesso.
    REQUIRE(usuarios.inserir(usuario("Ana", 30)).has_value());
    REQUIRE(usuarios.inserir(usuario("Bruno", 25)).has_value());
    REQUIRE(usuarios.inserir(usuario("Carla", 40)).has_value());
    REQUIRE(usuarios.tamanho() == 3);

    // Consulta encadeada: 28+ anos, ordenados por nome.
    auto res = usuarios.consultar()
        .filtrar([](const Row& r){ return r.get_inteiro("idade") >= 28; })
        .ordenar_por("nome");

    // Verifica o resultado da integração das operações.
    REQUIRE(res.tamanho() == 2);                        // Ana e Carla
    REQUIRE(res.linhas()[0].get_texto("nome") == "Ana");   // ordenado
    REQUIRE(res.linhas()[1].get_texto("nome") == "Carla");
}

TEST_CASE("fluxo de erro: inserção inválida é rejeitada e não corrompe o estado") {
    Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
    REQUIRE(t.inserir(usuario("Ana", 30)).has_value());

    Row invalida; invalida.set("nome", "Bruno");   // falta idade
    auto r = t.inserir(std::move(invalida));
    REQUIRE_FALSE(r.has_value());                  // rejeitada
    REQUIRE(r.error().codigo == CodigoErro::ColunaFaltando);
    REQUIRE(t.tamanho() == 1);                     // estado intacto: a válida continua lá
}

O segundo teste é especialmente valioso: ele verifica que uma falha de inserção não corrompe o estado — a tabela continua com sua linha válida, sem resquícios da tentativa inválida. Testar que os erros deixam o sistema consistente é um nível de rigor que separa código robusto de código apenas funcional. Testes de integração pegam bugs que os de unidade não veem: incompatibilidades sutis nas fronteiras entre peças.

Rodando sob os sanitizers

Fiel à Fase 8, submetemos o sistema aos sanitizers para caçar bugs de memória que os testes normais não expõem. Adicionamos um alvo de build instrumentado ao CMake e rodamos a suíte sob o AddressSanitizer:

# Opção para compilar com AddressSanitizer (para desenvolvimento/CI)
option(ENABLE_ASAN "Compilar com AddressSanitizer" OFF)
if(ENABLE_ASAN)
    add_compile_options(-fsanitize=address -g)
    add_link_options(-fsanitize=address)
endif()
# Configura e compila com ASan, depois roda os testes instrumentados:
cmake -S . -B build-asan -DENABLE_ASAN=ON
cmake --build build-asan
ctest --test-dir build-asan
# Se todos passarem SEM relatório de erro do ASan, temos alta confiança
# de que não há acessos inválidos, use-after-free ou vazamentos nos caminhos testados.

Rodar os testes sob ASan é o que dá confiança de que o banco não tem os erros de memória silenciosos que assombram C e C++. E aqui colhemos o fruto de todo o cuidado do curso: como construímos tudo com RAII, containers da STL e a Regra do Zero — sem um único new/delete cru —, o ASan tende a passar limpo. As decisões de design que tomamos lá atrás (Fase 2) se pagam agora, na forma de um sistema sem vazamentos por construção. Se tivéssemos gerenciado memória à mão, este seria o momento de caçar os bugs; como não gerenciamos, não há o que caçar.

Revisão à luz das Core Guidelines

O último passo do acabamento é uma revisão crítica do código contra as Core Guidelines (artigo A Sabedoria Codificada — Core Guidelines e Código Limpo em C++). Percorremos o sistema perguntando: cada função é focada? Os parâmetros de leitura são const&? Os recursos são geridos por RAII? As classes protegem seus invariantes? Vejamos um exemplo de refinamento que uma revisão dessas pega — tornar const um método que esquecemos, e usar const& onde havia cópia:

// ANTES (revisão pega): método não-const e parâmetro por valor desnecessário.
bool contem_coluna(std::string nome) {           // copia a string; não é const
    for (auto& c : esquema_) if (c.nome == nome) return true;
    return false;
}

// DEPOIS: const-correto e sem cópia — alinhado às guidelines F.16 e Con.
bool contem_coluna(const std::string& nome) const {   // const& + método const
    for (const auto& c : esquema_)
        if (c.nome == nome) return true;
    return false;
}

Rodar o clang-tidy (artigo Redes de Segurança Automáticas — Sanitizers e clang-tidy) sobre o projeto automatiza boa parte dessa revisão, apontando exatamente esses pontos (modernize-*, cppcoreguidelines-*). O objetivo não é perfeição pedante, mas eliminar os deslizes reais — cópias desnecessárias, métodos que deveriam ser const, oportunidades de usar a STL. Após essa passagem, o código não apenas funciona: ele é idiomático, o tipo de código que um revisor experiente aprovaria.

Teste de unidade e de integração pegam coisas diferentes: um garante que a peça faz o que promete, o outro que as promessas se encaixam. O teste de fluxo de erro é o mais revelador dos dois mundos — verificar que uma inserção inválida não deixa a tabela em estado inconsistente é exatamente a garantia que o RAII oferece quando uma exceção atravessa o programa.

O que foi construído aqui não é exercício de brinquedo: um banco em memória com integridade de esquema, consultas encadeáveis, política de erros coerente, suíte de testes e verificação por sanitizers. Cada peça é um encontro do que veio antes — variant e exceções no valor, RAII e STL na tabela, templates e lambdas nas consultas, expected nos erros, CMake e Catch2 na infraestrutura.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um teste de integração que exercite uma cadeia de três operações de consulta (filtrar, ordenar, e o selecionar do artigo A Terceira Pedra — Um Motor de Consultas com Templates e Lambdas) sobre uma tabela com pelo menos cinco linhas, verificando o resultado final.

Ver resposta

✓ Resposta: Teste de integração de três operações:

TEST_CASE("cadeia de três operações") {
    Table t({{"nome", Tipo::Texto}, {"idade", Tipo::Inteiro}});
    auto add = [&](const std::string& n, int64_t i){ Row r; r.set("nome",n); r.set("idade",i); t.inserir(std::move(r)); };
    add("Ana",30); add("Bruno",25); add("Carla",40); add("Diego",28); add("Elena",22);

    auto res = t.consultar()
        .filtrar([](const Row& r){ return r.get_inteiro("idade") >= 28; })  // Ana,Carla,Diego
        .ordenar_por("idade", false)                                        // 40,30,28
        .selecionar({"nome"});

    REQUIRE(res.tamanho() == 3);
    REQUIRE(res.linhas()[0].get_texto("nome") == "Carla");  // 40
    REQUIRE(res.linhas()[1].get_texto("nome") == "Ana");    // 30
    REQUIRE(res.linhas()[2].get_texto("nome") == "Diego");  // 28
    REQUIRE_FALSE(res.linhas()[0].tem("idade"));            // idade projetada fora
}

Exercício 2

Explique a diferença entre um teste de unidade e um teste de integração, usando exemplos do nosso banco. Por que ambos são necessários, e que tipo de bug cada um pega que o outro não pega?

Ver resposta

✓ Resposta: Um teste de unidade verifica uma peça isolada — por exemplo, que Value::como_inteiro() devolve o inteiro guardado e lança para tipo errado, sem envolver tabelas ou consultas. Um teste de integração verifica várias peças colaborando no fluxo real — por exemplo, criar uma tabela, inserir linhas e consultá-las, exercitando Value, Row, Table e Query juntos. Ambos são necessários porque pegam bugs diferentes: os de unidade isolam a lógica de cada peça e localizam precisamente onde um erro está (se Value falha, o teste de unidade de Value aponta direto), mas não veem problemas nas fronteiras entre peças; os de integração pegam justamente essas incompatibilidades de fronteira — por exemplo, se a Query esperasse que Row::get lançasse mas ele devolve optional, um teste de unidade de cada peça passaria, mas o de integração revelaria o descasamento. Unidade garante que cada peça está certa; integração garante que elas se encaixam.

Exercício 3

Compile o projeto completo com -DENABLE_ASAN=ON e rode a suíte de testes. Descreva o que significaria se o ASan reportasse um erro, e por que, tendo usado RAII e STL em todo o projeto, é provável que ele passe limpo.

Ver resposta

✓ Resposta: Se o ASan reportasse um erro, significaria que há um problema real de memória em algum caminho exercitado pelos testes: um acesso fora dos limites (por exemplo, indexar uma linha ou vetor além do tamanho), um use-after-free (usar um objeto destruído), ou um vazamento (memória alocada e nunca liberada) — bugs que seriam comportamento indefinido silencioso sem o ASan. Tendo usado RAII e STL em todo o projeto, é provável que passe limpo porque não gerenciamos memória manualmente: não há new/delete cru cuja liberação possamos esquecer (sem vazamentos), os containers da STL gerenciam seus próprios buffers com verificação interna, e os objetos têm tempo de vida bem-definido pelos escopos (sem use-after-free por gestão manual). Os acessos ainda poderiam estourar limites se indexássemos mal (v[i] com i inválido), mas usamos .at() ou verificações onde há dúvida. as decisões de design da Fase 2 eliminam por construção as categorias de erro que o ASan caça — a segurança não é verificada no fim, é embutida desde o começo.

Exercício 4

Revise o método abaixo (extraído do nosso banco) contra as Core Guidelines e reescreva-o corrigindo todos os deslizes que encontrar, explicando cada correção.

std::string descreve_linha(Row linha) {
    std::string s = "";
    for (auto par : linha.campos())
        s = s + par.first + "=" + par.second.para_texto() + " ";
    return s;
}
Ver resposta

✓ Resposta: Revisão do método:

// Deslizes: (1) parâmetro por valor copia a Row inteira; (2) não é const;
// (3) inicialização "" desnecessária; (4) concatenação com + em laço é ineficiente;
// (5) 'par' por valor copia cada par.
std::string descreve_linha(const Row& linha) {          // (1)(2) const& + const... 
    std::string s;                                       // (3) já nasce vazia
    for (const auto& [nome, valor] : linha.campos())     // (5) const& + structured bindings
        s += nome + "=" + valor.para_texto() + " ";      // (4) += em vez de s = s + ...
    return s;
}
// (2) se for método de classe, marcar 'const' no fim: ...const { ... }

Correções: o parâmetro virou const Row& (evita copiar a linha inteira, que contém um mapa de valores); se for método membro, deve ser const (só lê); removida a inicialização "" redundante (uma std::string já nasce vazia); trocado s = s + ... por s += ... (evita criar uma string temporária nova a cada iteração); e o laço usa const auto& [nome, valor] com structured bindings (evita copiar cada par e melhora a legibilidade). Cada mudança elimina uma cópia desnecessária ou torna a intenção mais clara — exatamente o que uma revisão contra as guidelines busca.

Exercício 5

O teste "fluxo de erro" desta aula verifica que uma inserção inválida não corrompe o estado da tabela. Explique por que essa propriedade (consistência após erro) é importante, e como ela se relaciona com a segurança contra exceções discutida no artigo Quando o Contrato se Quebra — Exceções e a Aliança com o RAII.

Ver resposta

✓ Resposta: A propriedade de consistência após erro — o sistema permanece num estado válido mesmo quando uma operação falha — é importante porque, sem ela, um erro não seria apenas um fracasso pontual, mas uma fonte de corrupção: se uma inserção inválida deixasse a tabela num estado meio-modificado (por exemplo, com uma linha parcial ou um contador dessincronizado), operações subsequentes sobre essa tabela dariam resultados errados ou travariam, e o bug se espalharia para longe da causa original, tornando-se muito difícil de rastrear. Garantir que a falha deixe o estado intacto contém o dano. Isso se relaciona diretamente com a segurança contra exceções do artigo Quando o Contrato se Quebra — Exceções e a Aliança com o RAII, que classifica as garantias que uma operação oferece diante de falhas — em particular a garantia forte: "se a operação falhar, o estado fica exatamente como estava antes de ela começar, como se nada tivesse sido tentado". Nosso inserir oferece essa garantia forte: ele valida antes de modificar (validar/checagem antes do push_back), então se a validação falha, nada foi alterado — a linha nunca chega a ser adicionada. Essa ordem — verificar tudo o que pode falhar antes de fazer qualquer modificação irreversível — é uma técnica central da programação segura contra exceções e de erros, e é o que faz nosso banco permanecer consistente diante de entradas inválidas. Erro que não corrompe é erro que se pode tratar com tranquilidade.

Comentários

Mais em Linguagem C++

Um Dono, Um Recurso — o std::unique_ptr
Um Dono, Um Recurso — o std::unique_ptr

Aqui o delete cru sai de cena. O unique_ptr possui um ponteiro do heap…

Tarefas em Segundo Plano e a Arte de Esperar — std::async e condition_variable
Tarefas em Segundo Plano e a Arte de Esperar — std::async e condition_variable

Quase sempre o que se quer não é gerenciar uma thread, e sim disparar uma…

Fazendo Duas Coisas ao Mesmo Tempo — std::thread
Fazendo Duas Coisas ao Mesmo Tempo — std::thread

Criar uma thread é a parte fácil. O C++ obriga a decidir entre join e detach…