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
- ISO C++ Core Guidelines: a revisão final do projeto se apoia nelas; releia as seções F (funções), C (classes) e R (recursos) com seu código em mãos.
- clang.llvm.org/extra/clang-tidy: para automatizar a revisão do projeto contra as guidelines.
- Anthony Williams, C++ Concurrency in Action, e Scott Meyers, Effective Modern C++: revisitar seções relevantes ao revisar o código do capstone.
- Michael Feathers, Working Effectively with Legacy Code: sobre testes de integração e caracterização, útil para entender o valor do que fizemos.
- "Google Test Primer" e a documentação do Catch2: para aprofundar em testes de integração e organização de suítes.
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.