1. Perché serve CTest, se abbiamo già doctest/Catch2?
Se avete appena imparato un test‑framework, è naturale chiedersi: «Ok, i test vengono eseguiti — perché un altro strumento come CTest?» Nella vita di un programmatore ci sono già abbastanza entità, soprattutto quando la scadenza vi sta guardando. Facciamo chiarezza sui ruoli e vediamo esattamente cosa aggiunge CTest.
Il test‑framework (doctest/Catch2) è una libreria all'interno del binario dei test. Sa trovare i test‑case, eseguirli, stampare in modo leggibile «expected/actual» e restituire il codice di uscita del processo 0 (successo) o != 0 (fallimento).
CTest è uno strumento dall'esterno. Non «capisce» i vostri TEST_CASE e non analizza i vostri CHECK. Fa un'altra cosa: organizza l'esecuzione dei test come parte del processo di build del progetto. In parole semplici, CTest è un dispatcher: «nel progetto sono registrati questi test, eseguiamoli tutti, raccogliamo le statistiche, mostriamo il risultato e restituiamo il codice di successo/fallimento all'esterno».
È utile tenere a mente questa tabella riassuntiva:
| Entità | Dove vive | Cosa fa | Cosa NON fa |
|---|---|---|---|
| Test‑framework (doctest/Catch2) | dentro l'eseguibile di test .exe | esegue i test‑case, stampa il report | non conosce come è organizzato il tuo progetto/la build |
| CMake | «ricetta di build» | compila/collega i target | di per sé non è un «test runner» |
| CTest | vicino a CMake (ecosistema) | esegue i test registrati e aggrega il risultato | non analizza i CHECK, gli interessa solo l'exit‑code |
2. Idea principale di CTest: il test è un comando con codice 0
Quando si sente «CTest», a un principiante può sembrare una modalità magica del compilatore: tipo, incorporerà i test nella build e poi si eseguiranno da soli, come il tempo fuori dalla finestra. In realtà il modello è molto concreto: un test è semplicemente un processo (un comando) che può essere eseguito, e il processo ha un codice di uscita.
Quasi tutta l'automazione nel mondo dello sviluppo si basa su un accordo molto semplice: 0 significa successo, qualsiasi altro numero significa errore. CTest non fa eccezione. Esegue i comandi di test e controlla: è tornato 0 oppure no. Se non è 0, il test è considerato fallito, anche se ha stampato «tutto ok, giuro».
Per provare con mano conviene vedere un micro‑test senza framework — un semplice «runner fatto in casa». Non è elegante, ma è molto onesto (come cout senza formattazione):
#include <iostream>
bool test_add() {
return (2 + 3) == 5;
}
int main() {
if (!test_add()) {
std::cerr << "test_add FAILED\n";
return 1; // <-- non 0: fallimento
}
std::cout << "test_add OK\n"; // test_add OK
return 0; // <-- 0: successo
}
Proprio per questo l'accoppiata «framework + CTest» funziona molto bene: il framework dentro il processo decide «passato/non passato» e imposta correttamente l'exit‑code, mentre CTest da fuori esegue i processi e aggrega il risultato.
Come CTest si integra nel progetto CMake
CMake sa generare i file per la build, e può anche generare un «registro dei test» — l'elenco dei comandi che sono considerati test del progetto. Questo elenco è poi letto da CTest.
L'integrazione di CTest in CMake richiede due azioni minime. Primo — dichiarare che nel progetto esistono test, abilita il supporto. Secondo — registrare comandi concreti come test.
In CMake si fa così:
enable_testing()
add_test(NAME core_tests COMMAND core_tests)
Il significato si legge quasi come in italiano: abilitato il testing, aggiunto il test con nome core_tests, che viene eseguito con il comando core_tests (di solito il nome dell'eseguibile dei test).
Importante: add_test registra una comando, non un «file di test». Se volete potete registrare come test qualsiasi cosa: lanci l'app con argomenti, uno script, un «format checker» — ma nel contesto qui ci concentriamo sugli unit test e sui binari di test.
3. Esempio pratico: test binary e registrazione in CTest
Ora costruiremo un piccolo ma completo «scheletro di progetto», con la logica (che testeremo), l'applicazione (lanciata dall'utente) e i test (eseguiti da CTest). Questo è importante didatticamente: se mischiate tutto in un solo main.cpp, sembra che «i test siano stati eseguiti», ma sarà difficile automatizzare e mantenere.
Immaginiamo di sviluppare una mini‑app didattica chiamata TinyCalc. Per ora fa due cose: clamp (restringe un numero in un intervallo) e safe_div (divide, ma ritorna std::optional se la divisione non è possibile). Niente magia — solo funzioni facili da testare.
Il codice «core» che testeremo
File src/core.hpp:
#pragma once
#include <optional>
int clamp(int x, int lo, int hi);
std::optional<int> safe_div(int a, int b);
File src/core.cpp:
#include "core.hpp"
int clamp(int x, int lo, int hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
std::optional<int> safe_div(int a, int b) {
if (b == 0) return std::nullopt;
return a / b;
}
Notate la «testabilità»: nessun std::cin, nessuna dipendenza dal tempo, nessun numero casuale. Passi parametri — ottieni risultato. Funzioni così si testano quasi senza attriti.
L'app che usa il core
File src/main.cpp:
#include <iostream>
#include "core.hpp"
int main() {
std::cout << clamp(15, 0, 10) << '\n'; // 10
const auto r = safe_div(10, 2);
if (r) {
std::cout << *r << '\n'; // 5
}
}
È solo una dimostrazione che la libreria è collegata e funziona. Si può testare il main con unit test, ma è un'altra disciplina: per l'idea di oggi è sufficiente testare il «core».
Binary di test con doctest/Catch2
File tests/core_tests.cpp:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "core.hpp"
#include <optional>
TEST_CASE("clamp clamps into [lo, hi]") {
CHECK(clamp(5, 0, 10) == 5);
CHECK(clamp(-1, 0, 10) == 0);
CHECK(clamp(999, 0, 10) == 10);
}
TEST_CASE("safe_div returns value or nullopt") {
CHECK(safe_div(10, 2) == std::optional<int>{5});
CHECK(safe_div(10, 0) == std::nullopt);
}
Questo file viene compilato in un eseguibile separato, ad esempio core_tests. Se lo eseguite direttamente, trova i test, li esegue e restituisce il corretto codice di uscita.
CMakeLists.txt: colleghiamo il tutto
Nella root del progetto: CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(TinyCalc LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(core src/core.cpp)
target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
add_executable(tinycalc src/main.cpp)
target_link_libraries(tinycalc PRIVATE core)
add_executable(core_tests tests/core_tests.cpp)
target_link_libraries(core_tests PRIVATE core)
enable_testing()
add_test(NAME core_tests COMMAND core_tests)
Qui ci sono due idee chiave.
- Prima idea: i test sono un eseguibile separato (target), come l'applicazione. Non mischiamo i test dentro tinycalc, altrimenti dopo rischiamo di confonderci su cosa stiamo eseguendo e perché.
- Seconda idea: add_test registra l'esecuzione di core_tests come test per CTest. CTest poi eseguirà quel binario e controllerà l'exit‑code.
4. Come eseguire CTest in pratica
Qui solitamente arriva il primo problema tipico del principiante: si lancia ctest, si ottiene un errore strano o si vedono zero test, e si dichiara solennemente: «CTest è rotto». In realtà quasi sempre non è CTest a essere rotto, ma la comprensione di da quale cartella lanciarlo e cosa deve essere già stato buildato.
CTest «vive» nella directory di build, perché è lì che CMake genera i file con l'elenco dei test. Quindi la logica è: prima configuri il progetto, poi compili (così appare l'eseguibile dei test), e solo dopo esegui ctest.
Un tipico flusso di comandi è:
cmake -S . -B build
cmake --build build
ctest --test-dir build
Se eseguite ctest dalla cartella build, spesso basta:
cd build
ctest
Cosa appare nell'output? Di solito CTest stampa quanti test ha trovato, quali ha eseguito e quali sono falliti. Dettaglio importante: se un test fallisce, CTest terminerà con codice != 0. Questo significa che, all'esterno (per l'automazione), il passo di testing è fallito.
Perché a volte appare «0 tests»
Se CTest riporta 0 test, quasi sempre è per uno dei due motivi: o avete dimenticato enable_testing() / add_test(...), oppure state eseguendo ctest dalla cartella sbagliata (non dove CMake ha generato la configurazione dei test).
Come vedere l'output di un test fallito
Un altro sorgente di sorpresa: il test è fallito ma non vedete il messaggio dettagliato (ad esempio quali valori erano «expected/actual»). Non è un bug — è una scelta di output. Spesso è comodo abilitare la modalità «mostra output in caso di fallimento»:
ctest --test-dir build --output-on-failure
O la modalità «molto verbosa»:
ctest --test-dir build -V
Il concetto è semplice: CTest può essere un «dispatcher silenzioso» o, su richiesta, un «dispatcher chiacchierone». In sviluppo la verbosità è utile, in pipeline talvolta è preferibile meno output.
Più test: nomi e filtraggio
Quando nel progetto c'è un solo binario core_tests, tutto è lineare e carino. Ma appena il progetto cresce, i test diventano diversi: ad esempio test di parsing, test dei container dei modelli, test del formatting. Qui CTest diventa particolarmente utile: può eseguire l'insieme di test come «catalogo» e permette anche di scegliere sottoinsiemi.
In CMake si ripete semplicemente add_test con nomi diversi:
add_executable(parser_tests tests/parser_tests.cpp)
target_link_libraries(parser_tests PRIVATE core)
add_test(NAME parser_tests COMMAND parser_tests)
Una buona abitudine: date al test (il parametro NAME ...) un nome leggibile nel report. test1 e test2 sono come le variabili a e b in contabilità: tecnicamente funzionano, ma poi vi creerete un rompicapo.
Quando i test sono molti, CTest permette di eseguire non tutto ma un sottoinsieme, ad esempio filtrando per espressione regolare nel nome del test. Concettualmente:
ctest --test-dir build -R core
Quindi «esegui solo i test il cui nome contiene core». Non è necessario ricordarselo a memoria, ma è utile sapere che non dovete lanciare sempre l'intero set se state lavorando su una singola sottosistema.
5. Come i test diventano parte del processo di build
La frase «parte della build» a volte suona come se i test venissero eseguiti durante la compilazione (come constexpr). In realtà «parte della build» significa parte del processo di verifica del progetto. Prima la build assicura che il codice compili e linki. Poi i test verificano che il comportamento non sia rotto.
È una separazione di responsabilità molto importante. Il compilatore verifica la forma: tipi, nomi, sintassi, linking. I test verificano il significato: «safe_div(10, 0) restituisce davvero nullopt», «clamp non esce dall'intervallo», «il parser non accetta spazzatura» — e così via.
Ed è qui che entra in gioco CTest: trasforma il «lanciare manualmente l'exe dei test» in un comando standard del progetto. Non dovete più ricordare il nome dell'eseguibile di test o dove si trova. Basta dire: «CTest, esegui i test del progetto» e lui lo fa, perché CMake ha già registrato i comandi necessari.
Vale la pena ricordare i generatori multi‑config (ad esempio in certe IDE), dove esistono Debug/Release. In quei casi può essere necessario indicare esplicitamente la configurazione a CTest, ma come idea principale ricordate: eseguite i test nella stessa configurazione in cui sono stati buildati. Se avete buildato Debug — eseguite i test Debug. Se avete buildato Release — eseguite i test Release.
6. Errori tipici usando CTest
Errore n.1: i test sono registrati ma l'eseguibile non viene buildato.
È facilissimo scrivere add_test(NAME core_tests COMMAND core_tests) e dimenticare che core_tests deve esistere come eseguibile. Se manca add_executable(core_tests ...), o non è stato buildato a causa di un errore di compilazione, CTest cercherà onestamente di eseguire qualcosa che non c'è. Si risolve facilmente: sistemate prima la build, poi eseguite i test.
Errore n.2: i test «non si trovano» perché ctest è lanciato dalla cartella sbagliata.
CTest non è un sensitivo: non scansionerà tutto il disco alla ricerca della vostra cartella build. Se lanciate ctest dalla root del repo mentre la configurazione dei test è in build/, CTest spesso vedrà «0 tests» o non capirà nulla. Abituatevi alla disciplina: lanciate ctest dalla cartella di build o specificate esplicitamente la directory dei test.
Errore n.3: i test dipendono da std::cin e aspettano interattività.
CTest esegue i test come comandi automatici. Se il vostro test durante l'esecuzione aspetta che l'utente inserisca qualcosa, CTest aspetterà insieme a lui — e sembrerà che «ctest sia bloccato». A livello di unit test si risolve con l'architettura: la logica va testata tramite funzioni con parametri, l'I/O rimane nel main.
Errore n.4: il test fallisce ma non vedete perché.
I principianti a volte pensano che CTest debba mostrare tutto l'output sempre. Ma può essere «silenzioso» e mostrare solo il fatto del fallimento. Se serve output dettagliato, attivate la verbosità (-V) o l'output in caso di fallimento (--output-on-failure). E naturalmente assicuratevi che il framework di test stampi la diagnostica su stderr/stdout come vi aspettate.
Errore n.5: il test stampa “FAILED”, ma ritorna 0.
Classico dei runner fatti in casa e di controlli scritti male. CTest non crede alle parole, ma al codice di uscita del processo. Se avete stampato «tutto male» ma avete restituito return 0;, per CTest il test è passato. L'accordo è semplice: successo — 0, fallimento — != 0, e conviene non discutere questo contratto, perché è usato da tutto il mondo dell'automazione.
GO TO FULL VERSION