CodeGym /Corsi /C++ SELF /Cosa testare per primo

Cosa testare per primo

C++ SELF
Livello 37, Lezione 0
Disponibile

1. Introduzione

Quando si sente «facciamo i test», la mente del principiante spesso immagina due orrori: o «bisogna testare tutto» (e vivere nei test), oppure «è roba per grandi aziende, qui abbiamo un progetto didattico». La realtà è più tranquilla: i test sono uno strumento, e ogni strumento va applicato dove rende di più.

Il problema è che tempo e attenzione sono limitati. Se iniziate a testare la «bellezza dell'output sulla console» e il «numero esatto di spazi», vi brucerete molto in fretta e penserete che i test siano il male. Ma se si parte dai posti giusti, i test diventano come la cintura di sicurezza: siete allacciati e potete andare più veloce senza temere ogni curva.

Idea chiave della lezione: i primi test devono proteggere ciò che si rompe più spesso e che è più difficile da debugare manualmente. Di solito non si tratta della «stampa del menu», ma di piccole funzioni, del parsing delle stringhe e delle regole di dominio.

2. Criteri semplici per la scelta: «facile da verificare» e «costoso da rompere»

Per scegliere consapevolmente, è utile avere in testa un modello semplice. Un buon candidato per un unit test è un pezzo di codice che è facile chiamare dal test (fornire input) e facile verificare (confrontare il risultato), e che in caso di rottura causa conseguenze spiacevoli: dati errati, regole sbagliate, stati strani.

Non serve una gestione del rischio complessa, basta una «tabella del buon senso»:

Cosa testiamo Perché è comodo Tipico costo della rottura
Funzioni pure (input → output) Deterministiche, non richiedono ambiente Calcoli/condizioni errati
Parser (stringa → struttura/numero) Molti casi limite, facile correggere con esempi Comandi «non riconosciuti», dati sporchi
Business logic (regole e operazioni) Comportamento più «prezioso», cambia spesso Il programma «funziona», ma sbaglia
Input/output, interattivo, «menu» Difficile da verificare in modo stabile senza infrastruttura Di solito non è l'errore più costoso all'inizio

È utile tenere anche un piccolo «filtro di testabilità». Se potete rispondere «sì» a queste domande, scrivere il test di solito è semplice: la funzione non legge da std::cin, non stampa su std::cout, non dipende da tempo/alea, il risultato dipende solo dai parametri, e potete descrivere l'output atteso con un paio di esempi.

Per togliere definitivamente il mistero, ecco lo schema decisionale:

flowchart TD
    A["Pezzo di logica che vogliamo verificare"] --> B{"Ci sono ingressi espliciti e un risultato esplicito?"}
    B -- "no" --> C["Refattorizziamo: estraiamo la logica da main/IO in una funzione"]
    B -- "sì" --> D{"Dipende da IO, tempo, random?"}
    D -- "sì" --> E["Separiamo: lasciamo l'IO fuori, la logica dentro"]
    D -- "no" --> F{"Ci sono casi limite e rischio di regressione?"}
    F -- "no" --> G["Si può rimandare, ma il test è comunque utile"]
    F -- "sì" --> H["Scriviamo unit test come priorità"]

Notate: anche quando la risposta è «no», spesso non vuol dire «non testiamo», ma «rifattorizziamo un po' per rendere testabile». È pratica ingegneristica normale, non una punizione.

3. Funzioni pure: la «prima tappa» ideale per i test

Una funzione pura è una funzione che segue il principio «ciò che riceve restituisce». Non accede a variabili globali, non legge input, non stampa, non apre file e non tenta di ricordare che giorno della settimana è. In un certo senso è la parte più onesta del programma: è facile da capire, facile da testare e raramente richiede «un'atmosfera speciale» per essere eseguita.

Nel nostro esempio didattico conveniamo di scrivere una piccola applicazione console chiamata Tasky — un mini task manager. I comandi sono stringhe: add ..., done 3, list. Oggi ci interessa la logica interna, non l'interfaccia.

Iniziamo dalla funzione che verifica la correttezza del titolo di un task. È un tipico esempio di logica pura: input — stringa, output — bool.

#include <string>

bool is_valid_title(const std::string& title) {
    return !title.empty() && title.size() <= 40;
}

Ora un «test» minimale si può scrivere con assert. Sì, non è ancora un framework di test, ma per cominciare va benissimo: veloce e chiaro.

#include <cassert>
#include <string>

bool is_valid_title(const std::string& title);

int main() {
    assert(is_valid_title("Read book"));                 // ok
    assert(!is_valid_title(""));                         // ok
    assert(!is_valid_title(std::string(41, 'A')));       // ok
}

Ciò che conta: abbiamo verificato non solo il «caso medio» (anche quello), ma i bordo. I bordi sono ciò che si rompe più spesso: «stringa vuota», «stringa troppo lunga», «esattamente al limite». È come con l'ascensore: con 3 persone tutto ok, ma con 10 e una bici iniziano i problemi.

Un altro scenario comune è la «normalizzazione» del testo. Per esempio vogliamo ignorare gli spazi ai bordi: l'utente inserisce " buy milk ", noi salviamo "buy milk". Anche questo è una funzione pura.

#include <string>

std::string trim_spaces(std::string s) {
    while (!s.empty() && s.front() == ' ') s.erase(s.begin());
    while (!s.empty() && s.back()  == ' ') s.pop_back();
    return s;
}

E il test per casi limite (notate che verifichiamo anche stringa vuota e stringa composta solo da spazi):

#include <cassert>
#include <string>

std::string trim_spaces(std::string s);

int main() {
    assert(trim_spaces("  hi ") == "hi");     // ok
    assert(trim_spaces("") == "");            // ok
    assert(trim_spaces("   ") == "");         // ok
}

Perché questo è «prima cosa da testare»? Perché queste funzioni verranno chiamate da più posti, sono piccole, e se sbagliano tutto il resto inizia a sembrare «strano». E lo «strano» è il peggiore tipo di bug: non crasha, vive.

4. Parser: stringa → dati

Il parser è il punto in cui la realtà sotto forma di stringhe cerca di diventare la vostra bella struttura. La realtà, come sappiamo, non è propensa alla bellezza: spazi in più, linee vuote, done -7, done abc, add senza testo, add , e tutto questo succederà inevitabilmente proprio quando pensate di avere finito.

La buona notizia: i parser sono quasi sempre deterministici. Date una stringa → ottenuto un risultato. Quindi sono comodi da testare. Ancora meglio: è molto vantaggioso testarli, perché il controllo manuale dei parser è un input interattivo infinito, dove vi stancate più in fretta della compilazione.

Iniziamo con un piccolo parser per interi: stringa → std::optional<int>. Se non va bene → std::nullopt.

#include <charconv>
#include <optional>
#include <string_view>

std::optional<int> parse_int(std::string_view s) {
    int value = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
    return value;
}

Testiamo casi tipici e di bordo: valido, negativo, spazzatura, vuoto.

#include <cassert>
#include <optional>
#include <string_view>

std::optional<int> parse_int(std::string_view s);

int main() {
    assert(parse_int("42") == std::optional<int>{42});   // ok
    assert(parse_int("-7") == std::optional<int>{-7});   // ok
    assert(parse_int("7x") == std::nullopt);             // ok
    assert(parse_int("") == std::nullopt);               // ok
}

Ora facciamo il parser del comando per Tasky. Per ora non ci serve std::variant, quindi descriviamo il comando con un semplice struct + enum class.

#include <optional>
#include <string>

enum class CommandType { Add, Done, List };

struct Command {
    CommandType type;
    std::string text; // per add
    int id = 0;       // per done
};

Il parser più semplice: se la stringa è list → comando List, se inizia con add → Add, se done + numero → Done. Tutto il resto → «non riconosciuto» (std::nullopt).

#include <optional>
#include <string>
#include <string_view>

std::optional<Command> parse_command(std::string_view line) {
    if (line == "list") return Command{CommandType::List, "", 0};
    if (line.starts_with("add ")) return Command{CommandType::Add, std::string(line.substr(4)), 0};
    if (line.starts_with("done ")) {
        auto id = parse_int(line.substr(5));
        if (!id) return std::nullopt;
        return Command{CommandType::Done, "", *id};
    }
    return std::nullopt;
}

E ora la parte più gustosa: i test non sono «un solo esempio», ma un set di rotture tipiche. Per i parser chiede a gran voce una tabella.

Input Aspettato
"list"
List
"add Buy milk"
Add con text
"Buy milk"
"add "
Add con text vuoto (e questo è un punto da decidere: la business logic lo proibisce o lo gestisce)
"done 3"
Done con id=3
"done abc"
nullopt
""
nullopt

Mini test (senza loop, solo per chiarire cosa verifichiamo):

#include <cassert>
#include <optional>
#include <string_view>

std::optional<Command> parse_command(std::string_view line);

int main() {
    assert(parse_command("list").has_value());            // ok
    assert(parse_command("done 3")->id == 3);             // ok
    assert(parse_command("done abc") == std::nullopt);    // ok
}

Notate un punto importante di design: non mescoliamo volutamente il «riconoscimento sintattico del comando» con le «regole del dominio». Il parser risponde alla domanda «cosa voleva fare l'utente sintatticamente?», la business logic deciderà dopo «si può eseguire?».

5. Business logic: regole del dominio

Business logic suona come parola da presentazioni aziendali, ma in un progetto didattico è semplicemente «le regole del gioco». Per esempio: un task non può avere titolo vuoto, il task deve avere un id unico, fare done su un id inesistente è un errore e non «mah, va bene». Questo è il comportamento che interessa all'utente e che più spesso si rompe con i cambiamenti.

La business logic solitamente provoca le regressioni più dolorose: il programma continua a funzionare, ma sbaglia. È peggio del crash, perché il crash è onesto.

Descriviamo il modello di task:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

Ora la regola di aggiunta: non si può aggiungere un task con titolo non valido. Mettiamo questa regola in una funzione che non stampa, ma restituisce bool. Così è facile da testare.

#include <vector>

bool add_task(std::vector<Task>& tasks, int id, std::string title) {
    title = trim_spaces(title);
    if (!is_valid_title(title)) return false;
    tasks.push_back(Task{id, title, false});
    return true;
}

E il test «successo/fallimento»:

#include <cassert>
#include <vector>

bool add_task(std::vector<Task>& tasks, int id, std::string title);

int main() {
    std::vector<Task> tasks;
    assert(add_task(tasks, 1, "Buy milk"));      // ok
    assert(tasks.size() == 1);                   // ok
    assert(!add_task(tasks, 2, "   "));          // ok (dopo trim diventa vuoto)
}

Poi l'operazione «marcare il task come completato». Qui è comodo usare optional: se il task è trovato — lo modifichiamo e ritorniamo true, altrimenti false. Di nuovo: niente cout, solo contratto.

#include <vector>

bool mark_done(std::vector<Task>& tasks, int id) {
    for (auto& t : tasks) {
        if (t.id == id) { t.done = true; return true; }
    }
    return false;
}

Test:

#include <cassert>
#include <vector>

bool mark_done(std::vector<Task>& tasks, int id);

int main() {
    std::vector<Task> tasks{{1, "A", false}};
    assert(mark_done(tasks, 1));                 // ok
    assert(tasks[0].done);                       // ok
    assert(!mark_done(tasks, 999));              // ok
}

Notate quanto questi test leggono «in modo umano»: come una storia. Preparate i dati → eseguite un'azione → verificate il risultato. Non lo avete ancora chiamato AAA, ma di fatto già pensate in quel modo.

Ora si vede chiaramente cosa è «vantaggioso testare per primo»:

Funzioni pure — perché sono i mattoni. Parser — perché hanno molte varianti di input. Business logic — perché è il senso del programma. Invece main() e i cicli di dialogo è meglio tenerli sottili e non cercare di testarli dal primo giorno.

6. Cosa non conviene testare per primo

È molto tentante (specialmente dopo il primo successo con assert) iniziare a testare tutto, inclusa la «bellezza con cui viene stampata la lista dei task». Questo è spesso una trappola: l'output è fragile, cambia per spazi, formattazione, localizzazione, e trasforma i test in guardiania degli spazi.

A voler essere gentili, testare std::cout da zero è come imparare a guidare partendo subito con un camion in retromarcia in un cortile stretto. Si può fare, ma perché?

La strategia corretta: se una parte del programma è difficile da testare, spesso il segnale non è «ok, non testiamo», ma «bisogna separare le responsabilità». Lasciate che main() legga la riga, chiami parse_command, poi chiami add_task/mark_done, e solo dopo stampi il messaggio all'utente. Così il comportamento principale è coperto dai test, mentre la stampa rimane una sottile interfaccia.

Questa idea si può rappresentare così:

flowchart LR
    A["main(): input/output"] --> B["parse_command(line)"]
    B --> C["apply_command(tasks, cmd)"]
    C --> D["risultato/stato"]
    D --> A

Testiamo per primi B e C (e le funzioni al loro interno), perché forniscono input/output stabili. main() resta minimale e «quasi immutato».

Test con assert come avvio rapido

A volte sembra che «se non c'è un framework di test non ci sono test». È un mito. All'inizio si può creare un file separato che viene eseguito e fallisce se un contratto è violato. I sistemi CI (e in generale qualsiasi controllo automatico) capiscono la regola semplice: exit code 0 — successo, non 0 — fallimento. E assert sa «lamentarsi forte» se una condizione è falsa.

Esempio di file di test semplice per Tasky, che verifica le tre aree più redditizie: funzione pura, parser, operazione di business.

#include <cassert>
#include <vector>

int main() {
    assert(is_valid_title("Ok"));                // ok
    assert(parse_int("12") == 12);               // ok

    std::vector<Task> tasks;
    assert(add_task(tasks, 1, " Read "));        // ok
    assert(tasks[0].title == "Read");            // ok
}

Un dettaglio importante: in alcune build assert può essere disabilitato (per esempio se definito NDEBUG). Questo non è un motivo per non scrivere controlli, è un motivo per capire che assert è un avvio rapido, mentre i test completi è meglio eseguirli con un binario di test separato. Ma questo lo vedremo dopo; l'obiettivo di oggi è scegliere gli obiettivi giusti per i test, non litigare sui framework.

7. Errori tipici nella scelta di «cosa testare per primo»

Errore №1: testare il guscio invece della logica.
I principianti spesso iniziano verificando che il programma stampi «Menu: 1) aggiungi 2) lista». Questo dà l'illusione di attività intensa, ma protegge poco dalle vere rotture. È molto più utile estrarre i calcoli, le verifiche e le operazioni in funzioni e testare quelle, lasciando main() sottile.

Errore №2: scrivere un «mega-test che fa tutto».
A volte si crea un test che aggiunge un task, poi fa done, poi list, poi altre cose, e alla fine un solo assert(true). Un test così è difficile da leggere e da riparare: se fallisce non si capisce dove e perché. Meglio scenari piccoli: un test — una regola di comportamento.

Errore №3: ignorare i casi limite.
Il test «add task normale» passa quasi sempre. Ma titolo vuoto, stringa di soli spazi, done abc, done -1, done 999 — questi sono i punti dove l'utente reale (o voi fra una settimana) metterà il piede sulla grata. Parser e validazione senza test sui bordi sono come un ombrello bucato: formalmente c'è, in realtà siete bagnati.

Errore №4: mescolare le responsabilità del parser e della business logic.
Se il parser comincia a decidere «si può aggiungere questo task» (e la business logic a sua volta «come riconoscere il comando»), il codice diventa confuso e i test innaturali. È meglio mantenere la separazione: il parser fa «stringa → comando», la business logic fa «comando → modifica dei dati», e i test si collocano naturalmente.

Errore №5: rendere i test nondeterministici.
Se un test dipende dal tempo corrente, da numeri casuali o dall'input interattivo, sarà «a volte verde, a volte rosso» senza cambiamenti nel codice. Questo rompe la fiducia nei test più di ogni altra cosa. I primi test devono essere granitici: input fissi, risultato atteso fisso, nessuna magia.

1
Compito
C++ SELF, livello 37, lezione 0
Bloccato
Titolo della scheda
Titolo della scheda
1
Compito
C++ SELF, livello 37, lezione 0
Bloccato
Bordi puliti
Bordi puliti
1
Compito
C++ SELF, livello 37, lezione 0
Bloccato
Controllo del numero
Controllo del numero
1
Compito
C++ SELF, livello 37, lezione 0
Bloccato
Lista di cose da fare
Lista di cose da fare
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION