38.4 CTest, negatív tesztek és sanitizer CI

A CI akkor hasznos minőségi kapu, ha minden állítása visszavezethető egy reprodukálható futtatásra. A „zöld futás” önmagában nem bizonyíték: a C és C++ viselkedése függ a nyelvi módtól, a compiler és a standard library verziójától, a target ABI-tól, a build típusától és attól, hogy milyen diagnosztikai runtime fut. Ezért a CI-t nem tesztparancsok gyűjteményeként, hanem konfigurációk és elvárt eredmények szerződéseként érdemes felépíteni. Az alábbiak a Függőségek, package discovery és linkhatárok (38.3) függőség- és linkhatárkezelésére, az Unit test, property test és fuzzing (16.3) tesztelési alapjaira, a Hibakezelési és exception safety labor (25.5) hibakezelési laborjára és a shared ownership, reclamation és konkurens review (34.6) konkurens élettartam-vizsgálatára épülnek. A CMake, GCC, Clang és binutils hivatalos dokumentációja a kiindulópont; a konkrét verziókat és kapcsolókat a laborprofilban kell rögzíteni, és a példák itt illusztrációk, nem lefuttatott mérések.

38.4.1 A tesztmátrix mint minőségi szerződés

A tesztmátrix a tesztkonfigurációk rendezett halmaza. Egy cellát a nyelvi standard, a compiler és verziója, a target ABI, a build típus, a sanitizer profil, a standard library és a szükséges külső komponensek azonosítanak. A cellához nem pusztán az tartozik, hogy „fut”, hanem egy elvárt eredmény: pass, expected-fail, skip vagy not-applicable. A mátrix akkor szerződés, ha minden cella reprodukálható: rögzített compilerparancs, környezeti változók, buildkönyvtár és tesztbemenet mellett ugyanazt az eredményt adja. A tesztek ehhez a konfigurációhoz rendelődnek, nem fordítva.

A dimenziók célja a kockázat arányos lefedése, nem a kombinációk robbanása. Egy tipikus vázlat:

Tengely Példaértékek Miért kerül be
Nyelv C17, C23, C++20, C++23 nyelvi és library-eltérések
Compiler GCC, Clang diagnosztika, optimalizálás, runtime
Build Debug, Release assertionök, optimalizálás, UB
Sanitizer nincs, ASan+UBSan, TSan memória-, UB- és versenyhibák
Target x86-64, AArch64 ABI, alignment, elérhető utasítások

A cellák elvárt eredményét indokolni kell. Ha egy kombinációt nem futtatunk, a mátrixban not-applicable vagy indokolt skip szerepeljen, ne csendes kihagyás. A CI-riportban a skipek száma és oka látható legyen; a kötelező cella skipje hiba, a nem kötelezőé elfogadható.

38.4.2 CTest és a keretrendszerek illesztése

A CTest nem unit teszt keretrendszer, hanem futtató és jelentéskészítő réteg: a add_test névvel és parancssal regisztrál egy folyamatot, a visszatérési kódot és a kimenetet értékeli, a teszteket címkékkel, fixture-ökkel és erőforrás-zárakkal rendezi. Az assertionöket a keretrendszer adja. A C++ oldalon a GoogleTest és a Catch2 opcionális választás; a konkrét verziót, a find_package módját és a discovery makrót a projektnek a választott keretrendszer hivatalos dokumentációjából kell ellenőriznie. A könyv itt nem rögzít third-party verziót, és nem állítja, hogy az alábbi integrációs részleteket lefuttatta.

Egy minimális C-s példa, ahol a diag_core a korábbi projekt könyvtára:

cmake_minimum_required(VERSION 3.20)
project(diag LANGUAGES C CXX)

add_library(diag_core src/core.c)
target_include_directories(diag_core PUBLIC include)
target_compile_features(diag_core PUBLIC c_std_17)

include(CTest)
if(BUILD_TESTING)
  add_executable(test_core tests/test_core.c)
  target_link_libraries(test_core PRIVATE diag_core)
  add_test(NAME core_parse COMMAND test_core)
  set_tests_properties(core_parse PROPERTIES LABELS "unit;fast")
endif()

A C++ keretrendszer illesztése ettől csak a futtató parancsban tér el; a CTest továbbra is a folyamatot és annak kimenetét látja. A gtest_discover_tests és a catch_discover_tests a keretrendszer saját eszköze, amely a teszteseteket külön CTest-tesztekké bontja. A részlet C++20-at és a Függőségek, package discovery és linkhatárok (38.3) szerinti csomagfelderítést feltételez; a pontos makróneveket és verziófeltételeket a hivatalos dokumentációban kell megerősíteni.

38.4.3 Negatív tesztek: compile-fail és runtime diagnostic

A negatív teszt olyan teszt, amelynek helyes kimenete egy hiba észlelése. Három fő alakja van: a fordításnak szándékosan sikertelennek kell lennie (compile-fail); a programnak sanitizer vagy assertion miatt nem nulla kóddal kell leállnia (runtime diagnostic); illetve a hibakezelő ágnak a várt hibakódot vagy hibastátuszt kell visszaadnia. A közös feltétel az izoláció: a negatív példa ne kerüljön a normál buildbe, és a teszt ne attól legyen „sikeres”, hogy valami véletlenül elromlott.

Compile-fail esetben a puszta „nem fordult” kevés, mert elgépelés vagy hiányzó include is okozhatja. A tesztnek a várt diagnosztikai kategóriát is ellenőriznie kell, lazán, mert a compiler üzenete nem stabil API. C++20-ban például a const eldobása egyértelmű hiba:

// tests/negative/const_discard.cpp
int main() {
    const int x = 1;
    int* p = &x; // hiba: const int* -> int*
    *p = 2;
}

C-ben ugyanez jellemzően csak figyelmeztetés, ezért ez a negatív teszt a mátrixban csak C++ módra érvényes. A futtatás wrapperrel történik, amely a visszatérési kódot és a mintát is ellenőrzi:

#!/usr/bin/env bash
set -euo pipefail
src=$1
pattern=$2
out=$(mktemp)
trap 'rm -f "$out"' EXIT
if "${CXX:?}" -std=c++20 -fsyntax-only "$src" >"$out" 2>&1; then
  echo "FAIL: a negatív teszt váratlanul lefordult" >&2
  exit 1
fi
if ! grep -Eq "$pattern" "$out"; then
  echo "FAIL: a várt diagnosztika nem jelent meg: $pattern" >&2
  cat "$out" >&2
  exit 1
fi
echo "OK: a fordítás a várt módon sikertelen"

A CTest-regisztráció a wrappert hívja, nem közvetlenül a compilert:

add_test(NAME compile_fail_const_discard
  COMMAND ${CMAKE_COMMAND} -E env CXX=${CMAKE_CXX_COMPILER}
          ${CMAKE_CURRENT_SOURCE_DIR}/run_compile_fail.sh
          ${CMAKE_CURRENT_SOURCE_DIR}/negative/const_discard.cpp
          "discards qualifiers|invalid conversion")

A minta compiler-specifikus; a cél a szabály megsértésének elutasítása, nem a pontos szöveg. A WILL_FAIL property önmagában itt sem elég, mert bármilyen nem nulla visszatérési kódot elfogad. Runtime diagnostic esetén ugyanez a helyzet: a sanitizer jelenlétét a kimenetből vagy a wrapperből kell igazolni, különben egy más okból leálló program is „elkapott hibának” látszana.

38.4.4 Sanitizer profilok: ASan/UBSan és külön TSan

Az AddressSanitizer (ASan) a memóriahibákat, a Use-after-free-t, a buffer-túlcsordulást és a leaket figyeli; az UndefinedBehaviorSanitizer (UBSan) a nem definiált viselkedés egy részét jelzi. A kettő együtt futtatható, de a UBSan csak akkor áll le, ha -fno-sanitize-recover=all szerepel. A ThreadSanitizer (TSan) az adatversenyeket keresi, és külön folyamatot, külön buildet igényel: az ASan és a TSan runtime-ja nem kombinálható egy targetben. A pontos támogatást és kapcsolókat a compiler dokumentációja szerint kell ellenőrizni; az alábbi CMake-részlet GCC/Clang, Linux, x86-64 feltételezéssel illusztráció.

add_library(sanitize_flags INTERFACE)
option(SANITIZE_ASAN "ASan+UBSan" OFF)
option(SANITIZE_TSAN "TSan" OFF)
if(SANITIZE_ASAN AND SANITIZE_TSAN)
  message(FATAL_ERROR "ASan/UBSan és TSan külön jobban futtatandó")
endif()
if(SANITIZE_ASAN)
  target_compile_options(sanitize_flags INTERFACE
    -fsanitize=address,undefined -fno-sanitize-recover=all
    -fno-omit-frame-pointer -g)
  target_link_options(sanitize_flags INTERFACE -fsanitize=address,undefined)
elseif(SANITIZE_TSAN)
  target_compile_options(sanitize_flags INTERFACE
    -fsanitize=thread -fno-omit-frame-pointer -g)
  target_link_options(sanitize_flags INTERFACE -fsanitize=thread)
endif()

A futtatás külön buildkönyvtárban és külön CI-jobban történik:

ASAN_OPTIONS=halt_on_error=1:abort_on_error=1:detect_leaks=1 \
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
ctest --test-dir build-asan --output-on-failure

A TSan job ugyanígy külön fut:

TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1 \
ctest --test-dir build-tsan --output-on-failure

A sanitizer build nem release build, és nem helyettesíti a teszteket: csak a lefuttatott kódra ad diagnosztikát. A teljesítményméréseket nem sanitizer alatt kell értelmezni, mert a runtime overhead jelentős.

38.4.5 Capability matrix és feltételes tesztek

A capability matrix a toolchain képességeit detektálja, nem a tesztek eredményét. Célja, hogy a C23-as vagy C++23-as kód ne a véletlen compiler-default miatt bukjon el, és hogy a nem támogatott képesség skipje látható legyen. A CMake check_c_source_compiles, check_cxx_source_compiles és check_cxx_compiler_flag makrói kis fordítási próbákat futtatnak. A próba eredménye alapján a teszt fut, vagy indokolt skip lesz:

include(CheckCXXSourceCompiles)
set(CMAKE_REQUIRED_FLAGS "-std=c++20")
check_cxx_source_compiles([=[
  #include <concepts>
  template<class T> concept Addable = requires(T a, T b) { a + b; };
  static_assert(Addable<int>);
  int main() { return 0; }
]=] HAVE_CXX20_CONCEPTS)

if(HAVE_CXX20_CONCEPTS)
  add_test(NAME concepts_requires COMMAND test_concepts)
else()
  add_test(NAME concepts_requires
    COMMAND ${CMAKE_COMMAND} -E echo "skip: nincs C++20 concepts")
  set_tests_properties(concepts_requires PROPERTIES SKIP_RETURN_CODE 0)
endif()

A SKIP_RETURN_CODE 0 itt azt jelenti, hogy a 0-val visszatérő helyettesítő parancs skipnek számít. A skip nem lehet csendes: a CI-összesítésben meg kell jelennie, és a kötelező celláknál hibának kell számítania. A C17/C23 és C++20/C++23 mátrixban külön kell kezelni a nyelvi és a library-támogatást; az N3096 és az N4950 nyilvános munkatervezet, nem az ISO kiadott szabványa, ezért a „támogatott” állítást a konkrét compiler- és library-verzióhoz kell kötni.

38.4.6 Fuzz corpus, failure injection és coverage korlátok

A fuzzelés bemenetgenerálás, nem bizonyítás. A corpus a kiinduló bemenetek verziózott halmaza; a fuzzer ezt mutálja, és a talált hibát reprodukáló bemenetet vissza kell tenni a corpusba. A CI-ben a corpus perzisztens cache-ből töltődik, a futtatás időkorlátos, és a fuzz target ASan/UBSan alatt fut. A fuzz nem determinisztikus, ezért a „nem talált hibát” nem lehet a helyesség bizonyítékaként kezelni; a regressziós corpus determinisztikus újrajátszása igen.

A failure injection szándékos, determinisztikus hibapontok beiktatása: allokációs hiba, I/O-hiba, hálózati hiba. A teszt beállítja a hibát, és a hibakezelő ág viselkedését ellenőrzi; ez a Hibakezelési és exception safety labor (25.5) laborjára épül. A hibapontok ne maradjanak a produkciós buildben, és a visszajátszásnak ugyanazt az állapotot kell előállítania.

A coverage (sor-, ág- vagy feltétellefedettség) azt méri, mit futtattunk, nem azt, hogy helyes. A 100%-os coverage öncél lehet, és a generált kód, a header-only könyvtárak, valamint a template-instantiationök torzíthatják. Ésszerűbb a kritikus ágakra, a hibakezelésre és a negatív tesztekre koncentrálni; a coverage-korlát legyen dokumentált és indokolt, ne a minőség egyetlen mérőszáma.

38.4.7 Gyakori hibák és javításuk

A leggyakoribb hiba a sanitizer és a release optimalizálás összekeverése: a -g hiánya olvashatatlan stack trace-t ad, a sanitizer nélküli „release” pedig nem ugyanazt a diagnosztikát nyújtja. A UBSan -fno-sanitize-recover nélkül csak jelent, a program tovább fut, és a CI zöld maradhat. Az ASan és a TSan egy targetben nem működik; külön job kell. A compile-fail teszt normál buildbe kerülése megszakítja a buildet; a negatív példáknak külön könyvtárban és külön regisztrációval kell élniük. A WILL_FAIL önmagában hamis zöldet ad, mert nem ellenőrzi a hiba okát. A feature detection elhagyása miatt a C++23-as kód a C++20-as compileren bukik, pedig a helyes eredmény skip lenne. A csendes skip ugyanilyen hiba: a mátrixban láthatóvá kell tenni. A fuzz corpus perzisztálásának hiánya miatt minden futás elölről kezd, a coverage 100%-ra hajtása pedig eltereli a figyelmet a hibakezelésről.

38.4.8 Feladatok

  1. Mátrixtervezés. Készíts tesztmátrixot a könyv diag projektjéhez legalább négy tengellyel. Minden cellához add meg az elvárt eredményt, és indokold, mely cellák not-applicable vagy skip jellegűek. Írd le, hogyan ellenőrzöd, hogy a mátrix nem csendes kihagyásokból áll.

  2. Compile-fail teszt. Írj C++20-as compile-fail tesztet, amely a const eldobását utasítja el. Készíts hozzá wrapper szkriptet és CTest-regisztrációt. A teszt akkor legyen sikeres, ha a fordítás sikertelen, és a kimenet tartalmazza a várt mintát. Add meg a build- és futtatási parancsot.

  3. ASan-negatív teszt. Adott egy szándékosan túlindexelő heap-tömb teszt. Készítsd el a CMake targetet és a CTest-regisztrációt úgy, hogy csak ASan buildben fusson, és a várt hiba esetén legyen sikeres. Magyarázd el, miért nem elég a WILL_FAIL.

  4. TSan-verseny. Készíts TSan-tesztet, amelyben két szál ír egy nem atomi globális változót. Add meg a külön TSan build és futtatás parancsát, és indokold, miért nem futtatható ez az ASan buildben. Nevezd meg a változó tárolási idejét, a szálak tulajdonviszonyát és a hibakimenetet.

  5. Fuzz és coverage. Tervezz fuzz stratégiát a parserhez: hogyan tárolod a corpus, hogyan reprodukálod a hibát, és milyen coverage-korlátot tartasz ésszerűnek? Különítsd el a determinisztikus regressziós futást a nem determinisztikus felfedezéstől.

38.4.9 Megoldások

38.4.9.1 1. Mátrixtervezés

Egy lehetséges vázlat: C17/GCC/x86-64/Debug/nincs sanitizer/pass; C23/GCC/x86-64/Debug/ASan+UBSan/pass vagy indokolt skip, ha a C23-as képesség hiányzik; C++20/Clang/x86-64/Release/nincs sanitizer/pass; C++20/Clang/x86-64/Debug/TSan/pass; C++23/Clang/x86-64/Debug/nincs sanitizer/expected-fail vagy skip a feature detection eredményétől függően. Az ASan+UBSan és a TSan egy cellában not-applicable. Az ellenőrzés: minden sorhoz tartozik buildparancs, környezeti változó és elvárt eredmény; a CI-riport a skipeket okkal együtt listázza; a kötelező cellák skipje külön hibaként jelenik meg.

38.4.9.2 2. Compile-fail teszt

A const_discard.cpp és a fenti wrapper használható. A futtatás:

cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build
ctest --test-dir build -R compile_fail_const --output-on-failure

Ellenőrzési feltétel: a wrapper visszatérési kódja 0, a compiler visszatérési kódja nem 0, és a kimenet illeszkedik a lazán megadott mintára. A minta compiler-specifikus; ha a minta nem illeszkedik, a teszt bukik, nem csendben átmegy. A std=c++20 és a compiler útja a laborprofilból jön.

38.4.9.3 3. ASan-negatív teszt

A szándékos hiba:

/* tests/sanitizer/asan_oob.c */
#include <stdlib.h>
int main(void) {
    int *p = malloc(2 * sizeof *p);
    if (!p) return 2;
    p[2] = 1; /* szándékos túlindexelés */
    free(p);
    return 0;
}

A CMake target csak ASan buildben létezik:

if(SANITIZE_ASAN)
  add_executable(asan_oob tests/sanitizer/asan_oob.c)
  target_link_libraries(asan_oob PRIVATE sanitize_flags)
  add_test(NAME asan_oob_detects
    COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/run_expect_sanitizer.sh
            $<TARGET_FILE:asan_oob> AddressSanitizer)
endif()

A wrapper elindítja a programot, elvárja a nem nulla visszatérési kódot és a sanitizer jellemző kimenetét. A WILL_FAIL azért nem elég, mert bármilyen nem nulla kódot elfogadna, például egy abort()-ot vagy egy hiányzó fájlt is. A teszt tulajdonosa a wrapper; a p heap-tömb tulajdonosa a main, élettartama a free-ig tart, az invalidáló művelet a free utáni hozzáférés vagy a határon kívüli írás; a hibakimenet az ASan jelentése.

38.4.9.4 4. TSan-verseny

// tests/tsan/race.cpp
#include <thread>
int x = 0;
int main() {
    std::thread a([]{ x = 1; });
    std::thread b([]{ x = 2; });
    a.join();
    b.join();
}

A build és futtatás:

cmake -S . -B build-tsan -DSANITIZE_TSAN=ON -DSANITIZE_ASAN=OFF
cmake --build build-tsan
TSAN_OPTIONS=halt_on_error=1 \
ctest --test-dir build-tsan -R tsan_race --output-on-failure

A x statikus tárolású, élettartama a program teljes futása; a két szál nem tulajdonosa a változónak, csak hozzáfér. Az invalidáló művelet a szinkronizáció nélküli, versengő írás; a hibakimenet a TSan adatverseny-jelentése. Az ASan buildben azért nem futtatható, mert az ASan és a TSan runtime-ja nem kombinálható; a két diagnosztika külön folyamatot igényel.

38.4.9.5 5. Fuzz és coverage

A corpus a tests/fuzz/corpus/ alatt verziózva, a CI cache-ből töltve; a futtatás időkorlátos, a fuzz target ASan+UBSan alatt fut. A hibát reprodukáló bemenet a corpusba kerül, és a regressziós futás determinisztikusan újrajátssza. A coverage-korlát a parser kritikus ágaira és a hibakezelésre vonatkozzon, ne a teljes generált kódra; a küszöb dokumentált, és nem helyettesíti a negatív teszteket. A felfedező futtatás nem determinisztikus, ezért a „nincs hiba” eredménye nem bizonyíték, csak a regressziós corpus és a sanitizeres futás ad ismételhető ellenőrzést.

38.4.10 Összefoglaló

A natív CI minőségi kapuja a tesztmátrix, nem a zöld szín. A mátrix cellái nyelvi módot, compilert, targetet, build típust és sanitizert rögzítenek, elvárt eredménnyel együtt. A CTest a futtató és jelentéskészítő réteg; a keretrendszer az assertionöket adja. A compile-fail és runtime diagnostic tesztek izoláltak, és a hiba okát is ellenőrzik, nem csak a nem nulla visszatérési kódot. Az ASan/UBSan együtt, a TSan külön jobban fut. A capability matrix a toolchain képességeit detektálja, a skip pedig látható és indokolt. A fuzz corpus perzisztens, a failure injection determinisztikus, a coverage pedig korlátos és nem öncél.