#engineering

Una suite di cui fidarsi: falsi verdi e falsi rossi esclusi per costruzione

Abbiamo progettato test e pipeline perché un verde significhi davvero “si può rilasciare”: le cause note di falsi verdi e falsi rossi sono escluse dalla forma del codice, dai tipi e dalla pipeline, e property testing e mutation testing verificano che i test sappiano accorgersi di un errore.

Qualità del software · TypeScript · Rust · Vitest · fast-check · Stryker · GitHub Actions

Quasi ogni progetto ha dei test. Molto meno spesso il team si fida davvero di quei test. Una suite che a volte fallisce senza motivo insegna a rilanciare la pipeline invece di leggere l’errore. Una suite costruita su mock che replicano l’implementazione si rompe a ogni refactoring, anche quando il comportamento non cambia, e resta verde quando un difetto si nasconde fra due componenti.

Per il backend di una piattaforma transazionale, con ordini, saldi e un registro contabile, abbiamo costruito la strategia di test partendo da un unico criterio: se i test passano, la modifica è sicura. Una suite tradisce questo criterio in due modi: con i falsi rossi, che insegnano a ignorarla, e con i falsi verdi, che rassicurano proprio quando non dovrebbero. Una regola che vive solo nella memoria del team cede alla prima scadenza stretta, quindi per ogni causa nota abbiamo cercato un modo di escluderla per costruzione: con la forma del codice, con i tipi, con la configurazione o con un controllo in pipeline. E poiché anche i test possono sbagliare, abbiamo aggiunto gli strumenti che mettono alla prova i test stessi.

Un test vale se sopravvive a un refactoring

La forma più diffusa di test unitario isola ogni classe dai suoi collaboratori con dei mock. Produce suite rapide e coperture alte, ma verifica come il codice lavora, non che cosa produce: basta riorganizzare l’implementazione perché decine di test diventino rossi senza che il comportamento sia cambiato. È il falso rosso più comune.

Seguiamo invece la scuola “classica” descritta da Khorikov: un test unitario verifica un’unità di comportamento, non una classe, e non tutto il codice merita lo stesso tipo di test.

Tipo di codice Esempi tipici Strategia
Logica complessa, pochi collaboratori calcolo delle commissioni, codec, parser test unitari esaustivi
Logica complessa mescolata a molto I/O regole di idoneità dentro un handler, fasi di un processo a più passi separare una decisione pura, testata a tabella, da un guscio sottile
Poca logica, molti collaboratori la maggior parte degli handler, una volta estratta la decisione test di flusso sul percorso principale
Codice banale registrazione degli handler, collegamenti nessun test

La divisione rende la diagnosi immediata: se i test unitari sono verdi e un test di flusso fallisce, il difetto è nei collegamenti, non nella regola.

Una regola, una funzione pura, una tabella

Molti handler reali mescolano una regola di business con letture e scritture su database e cache, e così la regola si verifica solo avviando tutto. La correzione istintiva è spezzarla in tante piccole funzioni, ma il flusso diventa illeggibile e i difetti finiscono fra le funzioni. Abbiamo adottato la forma che Seemann chiama impureim sandwich, leggi, decidi, agisci, con una sola funzione pura per caso d’uso, nello stesso file del suo handler. Non contiene I/O, orologio né configurazione: l’istante corrente e i limiti sono argomenti. Un esempio scritto per l’articolo:

type CancelDecision =
  | { action: 'cancel' }
  | { action: 'schedule'; at: Date }
  | { action: 'reject'; reason: 'already-settled' }

export const decideCancellation = (order: Order, now: Date, grace: Duration): CancelDecision => {
  if (order.status === 'SETTLED') return { action: 'reject', reason: 'already-settled' }
  const deadline = new Date(order.createdAt.getTime() + grace.milliseconds)
  return now < deadline ? { action: 'cancel' } : { action: 'schedule', at: deadline }
}

// Nell’handler: leggi, decidi, agisci.
const decision = decideCancellation(order, new Date(), config.grace)
switch (decision.action) {
  case 'cancel':
    /* … */ break
  case 'schedule':
    /* … */ break
  case 'reject':
    /* … */ break
  default:
    decision satisfies never
}

Due dettagli rendono la forma utile, non soltanto ordinata. Il ramo default fa sì che un nuovo esito non compili finché ogni chiamante non lo gestisce: una dimenticanza diventa un errore del compilatore. E il test è una tabella test.each in cui ogni riga ha un nome in linguaggio naturale, come “un ordine già regolato non si può annullare”: letta dall’alto in basso, la tabella è la specifica della regola.

Niente mock di ciò che controlliamo

Un mock del proprio database risponde ciò che chi l’ha scritto si aspetta dal database. Se l’aspettativa è sbagliata, codice e test sbagliano insieme, e il test resta verde. Per questo le dipendenze che controlliamo, database, cache e motore, sono sempre reali nei test. Le dipendenze esterne, come provider di pagamento o servizi email, sono l’unico posto ammesso per un sostituto, e solo dietro un confine che possediamo.

Ne discende una regola meno ovvia: mai spiare l’interno di un SDK. Uno spy su un metodo dell’ORM può cambiare il comportamento delle chiamate successive, così il task “riesce” senza aver scritto nulla e il test resta verde. Il rovescio della medaglia è che i test non entrano nel codice di produzione: niente if (test), niente implementazioni finte selezionate a runtime. Il codice verificato in pipeline è quello che gira in produzione.

Una configurazione in ingresso, un solo punto di avvio

Tutto questo funziona solo se il codice si lascia avviare dai test, con la configurazione dei test. La risposta da manuale è la dependency injection, ma portata attraverso ogni handler e ogni task introduce molto boilerplate per un beneficio che si ottiene in modo più semplice.

Abbiamo scelto singleton inizializzati in un solo punto, a partire dalla configurazione in ingresso: è la radice di composizione di Seemann e van Deursen, senza il resto del contenitore. loadConfig(env) valida la configurazione e ne ricava un oggetto tipizzato; start(config) crea database, cache, code, client esterni e client del motore, e restituisce lo stop() che li chiude in ordine. Ogni modulo espone le proprie risorse come binding ESM assegnati da start(), quindi chi le importa non porta con sé nessun contesto. Entrypoint e suite chiamano lo stesso start(), la suite con una configurazione di test tipizzata contro lo schema della produzione.

Questa forma ci ha dato tutto quello che serviva: un database isolato per ogni worker di test, i servizi avviati e fermati dentro un test per verificare riavvii e guasti, e funzioni pure testabili senza avviare nulla. Perché resti così, un test unitario importa ogni modulo del backend senza avviare nulla, e fallisce se un modulo legge la configurazione o apre una connessione all’importazione. Un vincolo che altrimenti vivrebbe nella memoria del team diventa un test che nessuna modifica futura può aggirare in silenzio.

Lo stesso motore del cloud, non un sostituto comodo

Le scorciatoie note, come SQLite al posto di PostgreSQL o un’istanza Valkey singola al posto di un cluster, rendono la suite più facile da avviare, ma ognuna sposta una parte del comportamento fuori da ciò che il test verifica. In un cluster Valkey i comandi su più chiavi sono ammessi solo se le chiavi appartengono allo stesso hash slot; un’istanza singola non ha questo vincolo, e un test può passare con un’operazione che in produzione verrebbe rifiutata. Per questo nei test ogni servizio ha la stessa topologia del cloud: il log del motore gira su un cluster Valkey a tre nodi, la cache sulla stessa configurazione del servizio gestito, e PostgreSQL alla stessa versione del database gestito.

Fedeltà non significa lentezza. PostgreSQL gira su tmpfs con le impostazioni “non durevoli” documentate dal progetto, e ogni worker di test riceve un clone di un database già migrato, che costa una decina di millisecondi, e il ripristino completo dello stato fra un test e l’altro, motore compreso, sta intorno ai 60 millisecondi: a questi costi l’infrastruttura reale è il modo normale di scrivere un test, non un lusso.

Un test instabile è un bug, non una seccatura

Un test che a volte fallisce è il falso rosso per eccellenza, e rilanciarlo automaticamente nasconde il sintomo e lascia la causa. Le cause dell’instabilità sono note, e per ciascuna c’è una regola che la attacca alla radice:

  • Stato: ogni test parte da uno stato noto, con tabelle svuotate e cache vuota, e le asserzioni riguardano solo le entità del test, mai conteggi globali.
  • Asincronia: niente sleep né polling. Il lavoro asincrono si esegue esplicitamente fino a coda vuota, con la stessa pipeline del worker di produzione, e il lavoro rimasto in coda a fine test fa fallire il test.
  • Tempo: le decisioni ricevono l’istante corrente come argomento; i test di flusso non spostano un orologio globale, ma predispongono dati con date nel passato.
  • Servizi remoti: nessun test dipende dalla rete o dalla disponibilità di terzi.

Test che inventano i casi: il property-based testing

Un test a esempi verifica i casi a cui qualcuno ha pensato. I difetti più costosi stanno nelle combinazioni a cui nessuno ha pensato: un annullamento che arriva fra la consegna di un lavoro e la sua riconsegna, una richiesta ripetuta dopo che altre operazioni hanno cambiato lo stato. Per questo, accanto ai test a esempi, abbiamo scritto test che generano i casi da soli e verificano invarianti.

Il più importante gira contro lo stack reale, con fast-check: genera sequenze casuali di operazioni, ordini, annullamenti, richieste ripetute con la stessa chiave di idempotenza, job consegnati due volte, chiusure e liquidazioni, e le esegue attraverso le vere API, i veri job e il vero motore. Alla fine di ogni sequenza verifica le invarianti che devono valere sempre: nessun errore del server, una richiesta ripetuta riceve la stessa risposta della prima, nessun saldo è negativo, ogni trasferimento del registro contabile ha due movimenti che si annullano, e i totali del registro coincidono con quelli che l’amministrazione vede.

Due scelte rendono il test affidabile. La prima è un controllo contro la vacuità: un’invariante che vale perché il generatore non raggiunge mai il caso interessante non dimostra nulla, quindi il test verifica che le sequenze abbiano davvero toccato tutti gli esiti previsti, dall’ordine eseguito alla liquidazione. La seconda riguarda la riproducibilità: in pipeline il seme è fisso e venti sequenze costano circa nove secondi; in locale si esplora con più esecuzioni e semi casuali, e una sequenza che fallisce si riesegue identica dal suo seme. Lo shrinking automatico è disattivato di proposito: una sequenza fallita viene stampata per intero, è abbastanza corta da leggersi, e si riesegue identica dal suo seme.

Lo stesso approccio copre il resto. Le funzioni pure, come le conversioni e la formattazione degli importi o i calcoli sulle date di calendario, hanno proprietà su input arbitrari: importi di qualsiasi grandezza, mesi di qualsiasi lunghezza. Il matching engine, scritto in Rust, ha le sue: due istanze che ricevono gli stessi comandi producono le stesse risposte, e il matching continuo produce lo stesso risultato di un’implementazione di riferimento volutamente ingenua.

Chi controlla i test: il mutation testing

La copertura dice quali righe i test eseguono, non se si accorgerebbero di un errore in quelle righe. Il mutation testing lo misura: introduce di proposito piccoli errori nel codice, un confronto invertito, una condizione rimossa, un valore cambiato, e controlla che almeno un test fallisca. Una mutazione che sopravvive indica un test che esegue il codice senza verificarlo.

Usiamo Stryker per TypeScript e cargo-mutants per il motore in Rust, con un’impostazione ripresa dalla pratica di Google (Petrović e Ivanković): ogni pull request muta solo le righe che modifica, e una mutazione che i test eseguono senza accorgersene blocca la pipeline. Non c’è una soglia globale da raggiungere, per una ragione precisa: il punteggio di un intero pacchetto mescola codice vecchio, mutazioni equivalenti e codice raggiunto solo dai test di integrazione, e una soglia invita a scrivere test per uccidere mutanti invece che per specificare comportamenti. Le mutazioni equivalenti sono escluse una per una, ciascuna con la sua motivazione. Su una pull request tipica il controllo costa pochi minuti e gira in parallelo agli altri test.

Il valore si vede nel tipo di difetto che il metodo fa emergere. Una mutazione classica allarga un filtro come where: { userId } a {}: l’operazione agisce sulle righe di tutti gli utenti invece che su quelle di uno solo. Un test che controlla solo le righe dell’utente che agisce non se ne accorge, perché le sue righe ci sono comunque. Per questa classe di difetti abbiamo introdotto un utente “spettatore”: le tabelle legate a un utente contengono una sua riga, un test fallisce se una nuova tabella ne è priva, e dopo ogni test l’harness verifica che nessuna sia cambiata. Una scrittura che raggiunge le righe di qualcun altro fa fallire il test, qualunque sia la funzionalità coinvolta. È la categoria che OWASP mette al primo posto fra i rischi delle API, e qui è controllata dall’harness, senza che nessuno debba ricordarsene.

Una pipeline che fallisce presto e rende obbligatorie le regole

La pipeline delle pull request esegue solo ciò che la modifica tocca, ma tratta le dipendenze condivise del monorepo (librerie comuni, lockfile, configurazione del workspace) come parte di ogni applicazione: una modifica al lockfile avvia tutto. I controlli vanno dal più economico al più costoso, così un errore di tipo interrompe il job senza aspettare container e database, e intanto l’infrastruttura di test si avvia in background subito dopo il checkout, per essere pronta quando servono i test di integrazione.

Sì Pull request Calcolo delle applicazioni interessate Frontend: typecheck, lint, test Backend Avvio infrastruttura di test in background Typecheck di codice e test Lint e build Test unitari Attesa infrastruttura pronta Test di integrazione e proprietà Motore: formattazione, lint, test, benchmark Mutation testing sulle righe modificate N Si può rilasciare

Che cosa significa un verde

Il risultato è che il verde della pipeline ha un significato preciso. Ogni regola di business è coperta caso per caso da una tabella che si legge come una specifica. I collegamenti fra API, code, cache e database sono verificati sugli stessi motori, alle stesse versioni e con la stessa topologia degli ambienti cloud. Le invarianti contabili reggono su sequenze di operazioni che nessuno ha scritto a mano. E le righe modificate vengono mutate a ogni pull request: un test che le esegue deve anche accorgersi di un errore, o la pipeline si ferma.

Per il team significa cambiare il codice con sicurezza, con test che sopravvivono ai refactoring. Per il cliente significa rilasciare più spesso, con meno incertezza. Ed è il modo in cui affrontiamo la qualità in generale: una regola che conta non resta una raccomandazione, ma diventa qualcosa che il codice, i tipi o la pipeline fanno rispettare.