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
- Niente codice senza test. Una PR senza test sul comportamento aggiunto non è completa.
- Test-first sui bug. Un fix inizia da un test che riproduce il bug (rosso) e finisce quando diventa verde.
- Deterministici. Nessuna dipendenza da orologio, rete o ordine casuale. Iniettare date/seed via
RunContext.params. - Veloci. Il livello unitario gira in millisecondi e non tocca sistemi esterni.
Piramide dei test
| Livello | Cosa copre | Strumenti | Dove |
|---|---|---|---|
| Unit | Trasformazioni, regole di business, schemi Zod | vitest, dati inline | *.test.ts accanto al file |
| Pipeline (component) | Flusso Source → Transform* → Sink end-to-end | arraySource / arraySink in-memory | src/pipelines/<nome>/*.test.ts |
| Integration | Connettori reali verso DB/API (con container o sandbox) | vitest + testcontainers/sandbox | tests/integration/ (eseguiti separatamente) |
| Contract | Lo schema della sorgente non è cambiato sotto di noi | Snapshot dello schema Zod | tests/contracts/ |
Cosa DEVE testare ogni pipeline
Per ogni pipeline, almeno:
- ✅ Happy path: record validi → output atteso,
report.writtencorretto. - ✅ Validazione: un record non conforme → instradato al dead-letter,
report.rejectedcorretto, 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
pnpm run test # tutta la suite, una volta
pnpm run test:watch # TDD
pnpm run test:cov # con report e soglie di coverageCatalogo 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.
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.tsConvenzioni che rendono il catalogo leggibile:
- Colocazione: gli unit test stanno accanto al codice (
*.test.ts); i test di integrazione intests/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 a127.0.0.1— il kernel non lo considera un conflitto. Da lì lafetchversohttp://127.0.0.1:<porta>viene servita dall'altro processo, perché l'indirizzo specifico vince sul wildcard. Chiedendo127.0.0.1il kernel rifiuta conEADDRINUSEuna 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.

