Skip to content

Strategia di test

I test non sono un optional. Sono il contratto che ci permette di rifattorizzare pipeline dati senza rompere i clienti a valle.

Principi

  1. Niente codice senza test. Una PR senza test sul comportamento aggiunto non è completa.
  2. Test-first sui bug. Un fix inizia da un test che riproduce il bug (rosso) e finisce quando diventa verde.
  3. Deterministici. Nessuna dipendenza da orologio, rete o ordine casuale. Iniettare date/seed via RunContext.params.
  4. Veloci. Il livello unitario gira in millisecondi e non tocca sistemi esterni.

Piramide dei test

LivelloCosa copreStrumentiDove
UnitTrasformazioni, regole di business, schemi Zodvitest, dati inline*.test.ts accanto al file
Pipeline (component)Flusso Source → Transform* → Sink end-to-endarraySource / arraySink in-memorysrc/pipelines/<nome>/*.test.ts
IntegrationConnettori reali verso DB/API (con container o sandbox)vitest + testcontainers/sandboxtests/integration/ (eseguiti separatamente)
ContractLo schema della sorgente non è cambiato sotto di noiSnapshot dello schema Zodtests/contracts/

Cosa DEVE testare ogni pipeline

Per ogni pipeline, almeno:

  • Happy path: record validi → output atteso, report.written corretto.
  • Validazione: un record non conforme → instradato al dead-letter, report.rejected corretto, la pipeline non aborta.
  • Idempotenza: due run con lo stesso input non producono duplicati a valle (test sul sink).
  • Mapping/business: i calcoli (sconti, conversioni, aggregati) testati su casi noti, inclusi i bordi (0, negativi, null).

Esempio di riferimento: src/pipelines/langfuse-sessions-daily/pipeline.test.ts.

Coverage come gate

Le soglie sono in vitest.config.ts (lines 80%, branches 75%). La CI fallisce sotto soglia. Regole:

  • Non abbassare le soglie per far passare una build. Se serve davvero, motivalo in un ADR.
  • Il coverage misura che il codice gira, non che sia corretto: usalo come rete di sicurezza, non come obiettivo.

Dati di test

  • Usa fixture sintetiche, mai dati reali di clienti (privacy + git).
  • Per dataset realistici, genera dati con factory deterministiche.
  • I file fixture vivono accanto al test o in tests/fixtures/.

Comandi

bash
pnpm run test         # tutta la suite, una volta
pnpm run test:watch   # TDD
pnpm run test:cov     # con report e soglie di coverage

Catalogo dei test

Non manteniamo un elenco statico dei test (marcirebbe): la fonte di verità sono i test stessi, interrogabili on-demand. Così l'elenco è sempre accurato.

bash
pnpm run test:list        # elenco leggibile: file > describe > it (suite unitaria/CI)
pnpm run test:list:json   # stesso elenco in JSON (per tooling/report)

# i test di integrazione vivono in una config separata:
pnpm exec vitest list --config vitest.integration.config.ts

Convenzioni che rendono il catalogo leggibile:

  • Colocazione: gli unit test stanno accanto al codice (*.test.ts); i test di integrazione in tests/integration/ (auto-skip senza i servizi esterni).
  • Nomi descrittivi: describe = unità sotto test, it = comportamento atteso (in italiano, una frase). Es. "buildUpsertText > usa DO NOTHING quando tutte le colonne sono nella chiave di conflitto".

Test che avviano un server HTTP

Chi ha bisogno di un server vero usa listenOnLoopback() (tests/helpers/http-listen.ts), mai app.listen(0) a mano. Due motivi, entrambi già costati un rosso intermittente:

  • Si ascolta dove si va a bussare. app.listen(0) senza host binda il wildcard IPv6 ::, e su BSD/macOS quel bind riesce anche se la porta è già occupata da un listener legato specificamente a 127.0.0.1 — il kernel non lo considera un conflitto. Da lì la fetch verso http://127.0.0.1:<porta> viene servita dall'altro processo, perché l'indirizzo specifico vince sul wildcard. Chiedendo 127.0.0.1 il kernel rifiuta con EADDRINUSE una porta contesa, quindi non può assegnarcela.
  • Un bind fallito deve dirlo. L'helper ascolta l'evento error, così il test fallisce con la sua causa invece di restare appeso fino al timeout.

Vale la regola generale: attese con asserzione, mai cicli che scadono in silenzio. Un for che smette di girare dopo N tentativi e prosegue senza controllare nulla sposta il fallimento a valle, dove non si capisce più da dove viene; vi.waitFor(() => expect(...)) fallisce sul posto dicendo cosa manca.

Noeva è un marchio registrato di 4D S.R.L.