CodeGym /Corsi /C++ SELF /CTest — eseguire i test come parte della build

CTest — eseguire i test come parte della build

C++ SELF
Livello 37, Lezione 3
Disponibile

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.

1
Compito
C++ SELF, livello 37, lezione 3
Bloccato
Runner dei test
Runner dei test
1
Compito
C++ SELF, livello 37, lezione 3
Bloccato
Due set di test
Due set di test
1
Compito
C++ SELF, livello 37, lezione 3
Bloccato
Modalità dello strumento
Modalità dello strumento
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION