Architettura di dati e sistemi, dalle fondamenta Lezione 51 / 80

CI per data pipeline: testare senza bruciare un cluster

Unit test sulle trasformazioni, integration test su sample data, il loop di sviluppo local-first. Perché la CI per i dati è diversa dalla CI per i web service.

La continuous integration per un web service è una strada ormai battuta. A ogni push, la test suite gira sul nuovo codice, finisce in qualche minuto, e la luce verde ti dice che il cambiamento è sicuro da mergiare. La forma della test pyramid è in larga parte assodata: tanti unit test, meno integration test, uno strato sottile di end-to-end test, e il tutto sta comodamente dentro un CI runner con qualche gigabyte di RAM.

La CI per le data pipeline non sta comoda da nessuna parte. La cosa che vuoi testare gira su terabyte. Il cluster che la esegue costa soldi veri al minuto. Il bug che stai cercando di intercettare spesso si manifesta solo quando l’input ha la distribuzione disordinata dei dati di produzione reali, che non puoi copiare dentro un CI runner. Esegui la pipeline vera su ogni pull request e la bolletta del cloud diventa un suo proprio problema strutturale; non eseguirla affatto e spedisci bug in produzione.

Il compromesso su cui l’industria si è assestata è a strati, la stessa idea della test pyramid per il software ma con spessori diversi e tooling diverso. Questa lezione attraversa gli strati, gli strumenti che si adattano a ciascuno, e i pattern che rendono la CI delle pipeline allo stesso tempo veloce e affidabile.

Perché la CI per i dati è diversa

Tre proprietà del lavoro sui dati tirano la CI in direzioni che la CI per i web service non deve gestire.

Le pipeline sono stateful per definizione. L’output di un’esecuzione è l’input della successiva. Uno unit test su una singola trasformazione è informativo ma non sufficiente; vuoi anche sapere che la trasformazione si compone correttamente con tutto quello che le sta intorno. I test dei web service raramente devono pensare alla continuità dello stato, perché ogni request è pensata per essere indipendente.

Il blast radius di un bug è più grande e più silenzioso. Un bug in un servizio produce errori 5xx che il monitoring intercetta in pochi minuti. Un bug in una pipeline scrive righe sbagliate dentro una tabella di cui job downstream e dashboard si fidano. Quando qualcuno si accorge che il conteggio degli utenti è sbagliato di un fattore due, i dati sbagliati sono già in una dozzina di tabelle derivate. La ragione per intercettare i bug delle pipeline prima del merge è più forte, non più debole, rispetto ai web service.

I dati di test reali sono difficili. Non puoi spedire dati di produzione in CI senza incappare in problemi di privacy e regolamentari. Non puoi generare dati sintetici che corrispondano alla distribuzione di quelli reali senza fatica. Il default del “testare contro un piccolo sample inscatolato” è corretto, ma devi essere onesto sul fatto che il sample manca intere categorie di bug che solo la distribuzione reale espone.

Queste tre proprietà danno forma a ogni scelta che segue.

La piramide della CI per le pipeline

La test pyramid dei web service ha tre strati: unit test in fondo, integration test al centro, end-to-end test in cima. La stessa forma funziona per le pipeline, con i contenuti degli strati ridefiniti.

Unit test sono lo strato di fondo. Girano su piccoli dati in memoria, in millisecondi, nel linguaggio in cui è scritta la pipeline. Le trasformazioni a funzione pura sono unit-testabili: passi una fixture, fai assert sull’output. Una funzione che prende un DataFrame di righe di viaggi e restituisce un DataFrame di righe di viaggi puliti può essere testata con un input di cinque righe. Codice PySpark, macro dbt, funzioni user-defined Flink, trasformazioni Pandas: tutti hanno un nucleo unit-testabile se il codice è strutturato attorno a funzioni pure invece che a un singolo job script di 800 righe.

La disciplina che rende possibili gli unit test è separare la business logic dall’I/O. Un job che legge da S3, trasforma, scrive su un warehouse, e orchestra i retry è difficile da unit-testare. Lo stesso job, refattorizzato in modo che la trasformazione sia una funzione che prende un DataFrame e restituisce un DataFrame e l’I/O sia in un wrapper sottile, è facile da unit-testare. La maggior parte delle lamentele del tipo “il codice dei dati è difficile da testare” si riducono a “questo job ha le sue trasformazioni intrecciate con l’I/O”.

Integration test sono lo strato di mezzo. Eseguono la pipeline end-to-end su un dataset di sample, qualche migliaio di righe, in CI. Il punto è intercettare bug che gli unit test mancano: schema mismatch tra stadi, colonne droppate per sbaglio, chiavi di join sbagliate, contratti rotti tra trasformazioni. Il sample è abbastanza piccolo da starci in un CI runner, abbastanza grande da esercitare ogni percorso di codice.

Le sample fixtures vivono nel repo. Input canonici in file CSV o Parquet, committati, trattati come parte della test suite. Quando la pipeline cambia forma, le fixtures vengono aggiornate nella stessa pull request del codice che ne ha bisogno. I revisori vedono entrambe le cose insieme.

End-to-end test sono lo strato in cima. Girano su uno staging cluster contro dati realistici-ma-anonimizzati. Sono lenti e costosi, quindi girano di notte o settimanalmente, non a ogni pull request. Intercettano le cose che il piccolo sample non può: bug da data skew, regressioni di performance sul volume reale, interazioni con l’orchestrator, comportamento dei retry sotto fallimento parziale.

La forma piramidale conta. La maggior parte dei team che sbagliano la CI per i dati lo fa invertendo la piramide: tante esecuzioni end-to-end costose, pochi unit test, nessuno strato di integration nel mezzo. La bolletta del cloud sale e il feedback loop rallenta, e i bug che il team ha effettivamente sono bug a forma di unit test che gli end-to-end test lenti intercettano solo per caso.

dbt: test dichiarativi come prima fermata

Per le pipeline a forma di SQL, le convenzioni del progetto dbt sono il primo posto dove guardare. dbt arriva con un set di test dichiarativi che attacchi a colonne o modelli in YAML.

I quattro test built-in coprono la maggior parte dei bug in cui incapperai:

  • not_null: questa colonna non dovrebbe mai avere valori null.
  • unique: questa colonna non dovrebbe avere duplicati.
  • accepted_values: questa colonna dovrebbe contenere solo valori da un insieme noto.
  • relationships: questa foreign key dovrebbe esistere nella tabella referenziata.

I custom test sono query SQL arbitrarie che dovrebbero restituire zero righe quando i dati sono sani. “Nessuna riga dovrebbe avere un importo negativo.” “Nessun cliente dovrebbe avere più subscription aperte del consentito.” “I totali di ieri dovrebbero stare entro il cinque percento della media a sette giorni.” Ognuna è una query il cui risultato non zero è un test fallito.

In CI, i test dbt girano contro un warehouse di dev più piccolo: un dataset BigQuery separato, uno schema Snowflake separato, un database Postgres separato. La pull request triggera un job che esegue dbt build contro questo target isolato, esegue dbt test per verificare le assertion, e riporta indietro. Se qualcosa fallisce, la pull request diventa rossa.

Great Expectations è un cugino più flessibile per setup non-dbt. Il modulo 8 copre la data quality più in profondità; per ora, tratta i test dbt e Great Expectations come lo strato dichiarativo, e pytest come lo strato imperativo per la logica a forma di codice che non sta pulita dentro una assertion SQL.

pytest per il lato Python

Per pipeline basate su Python (PySpark, Pandas, librerie di trasformazione custom), pytest è lo standard. I pattern sono gli stessi di qualsiasi codebase Python, con due extra che vale la pena segnalare.

I dati delle fixture vivono nel repo. Una directory tests/fixtures/ con piccoli file CSV o Parquet. Una pytest fixture li carica in un DataFrame all’inizio di ogni test. Le fixtures sono parte della code review: se cambi lo schema dell’input, cambi la fixture, e i revisori vedono entrambe le cose.

Per PySpark, una SparkSession configurata in local mode in cima al modulo di test ti dà uno Spark vero in CI. È più lento del Pandas puro ma esercita il percorso di codice reale. Il pattern su cui la maggior parte dei team si assesta è: testare la logica di trasformazione con fixtures equivalenti in Pandas per la velocità, poi avere una integration suite più piccola che conferma che la stessa logica funziona su Spark con una sessione minuscola.

Il pattern del fresh warehouse

Lo strato di integration spesso ha bisogno di più di un solo schema di dev. La versione pulita è: ogni pull request fa lo spin-up di un warehouse temporaneo, completamente isolato, esegue la pipeline contro di esso, valida l’output, e lo butta giù.

Per BigQuery, questo significa un dataset fresco nominato come la pull request, droppato al merge o alla chiusura. Per Snowflake, uno schema fresco clonato da un piccolo schema di riferimento. Per Postgres, un database fresco in una istanza Postgres di CI.

Il beneficio è l’isolamento: due pull request non possono interferire l’una con l’altra, e l’ambiente di test è pulito ogni volta. Il costo è l’orchestrazione: setup e teardown devono essere affidabili, e il cleanup deve gestire pull request che vengono abbandonate senza essere mergiate.

Il pattern si guadagna il pane nei team dove più cambiamenti di pipeline sono in volo contemporaneamente. Per un piccolo team con uno o due ingegneri, un singolo dev warehouse con convenzioni su chi lo sta usando funziona bene.

flowchart TB
    PR[Pull request opened] --> SETUP[Spin up dev schema]
    SETUP --> BUILD[dbt build on sample data]
    BUILD --> TEST[dbt test plus pytest]
    TEST --> REPORT[Report status to PR]
    REPORT --> TEARDOWN[Teardown dev schema]
    TEARDOWN --> APPROVE[Approve and merge]

Quando “testa in produzione” è la risposta giusta

C’è una corrente di consigli nella community della data engineering secondo cui “non puoi davvero testare una pipeline finché non gira sui dati veri”. Detto così è troppo forte, ma punta verso qualcosa di vero. I dati reali hanno i bug che vuoi intercettare di più: distribuzioni skewed, righe in arrivo in ritardo, valori malformati da sorgenti upstream, la lunga coda di edge case che i dati sintetici non riproducono mai.

La disciplina che rende sicura questa cosa non è “saltare la CI”. È “fare deploy su uno strato parallelo, validare, poi commutare”. La lezione 36 ha introdotto la medallion architecture; il silver layer è un posto naturale dove far atterrare l’output di una nuova versione senza che i consumer lo vedano. Una nuova versione della pipeline scrive su una tabella laterale per una settimana; il team confronta l’output della tabella laterale con l’output di produzione; una volta che le diff sono piccole e spiegabili, il puntatore del consumer si gira.

Questo è il dark-launch pattern dalla lezione 52, applicato al livello del testing piuttosto che al livello del deployment. La CI ti dà fiducia che il codice sia strutturalmente corretto. Il dark launch ti dà fiducia che sia corretto sulla distribuzione reale. Sono strati, non alternative.

Cosa significa nella pratica

Il setup realistico di CI per un data team parte piccolo e cresce.

Il giorno uno è una suite pytest per le funzioni di trasformazione e uno step dbt test per i modelli SQL. Solo questo intercetta il grosso dei bug e gira in qualche minuto.

Il giorno trenta aggiunge lo strato di integration: un piccolo input canonico, eseguito end-to-end attraverso la pipeline in CI, output validato. Le fixtures vivono nel repo, la pull request vede la stessa forma di dati che vede la pipeline di produzione.

Il giorno novanta aggiunge il pattern del fresh warehouse se più cambiamenti sono in volo contemporaneamente, oppure resta su un singolo dev warehouse se non lo sono. L’esecuzione end-to-end notturna comincia a esistere come una pipeline separata nell’orchestrator, contro dati realistici anonimizzati.

Quello che il team non fa, mai, è eseguire l’intera pipeline di produzione sull’intero volume di produzione a ogni pull request. Quella è la strada da cui la bolletta del cloud non si riprende mai.

La CI è l’upstream della CD. La lezione 52 ha coperto i pattern di deployment una volta che il cambiamento è mergiato. Le due cose lavorano insieme: la CI intercetta i bug, la CD limita il blast radius di quelli che sfuggono.

Riferimenti

  • dbt documentation, “Tests” (https://docs.getdbt.com/docs/build/tests, consultato 2026-05-01).
  • Great Expectations documentation (https://docs.greatexpectations.io/, consultato 2026-05-01).
  • pytest documentation (https://docs.pytest.org/, consultato 2026-05-01).
  • “Continuous integration for data” sul blog di dbt (https://www.getdbt.com/blog/, consultato 2026-05-01).
Cerca