Adrienne Vermorel
Unit Testing vs. Data Testing : Quand utiliser chaque approche
Ceci est la Partie 3 d’une série en 3 parties sur les tests unitaires dbt. En s’appuyant sur l’implémentation (Partie 1) et les patterns (Partie 2), cet article fournit un cadre stratégique pour choisir la bonne approche de test.
Vous avez appris à écrire des tests unitaires. Vous connaissez les patterns pour les modèles incrémentaux, les fonctions de fenêtrage et l’analytics marketing. Mais voici la question qui revient constamment en code review : “Est-ce que ça devrait être un test unitaire ou un test de données ?”
L’écosystème de test dbt s’est considérablement développé. Entre les tests unitaires natifs, les tests génériques, les tests singuliers, dbt-expectations, Elementary et dbt-audit-helper, les options peuvent sembler accablantes. Cet article coupe court à la complexité avec un cadre de décision clair.
La taxonomie des tests dbt
Avant de pouvoir décider quand utiliser chaque type de test, nous devons comprendre ce que chacun fait.
Tests unitaires
Vous les avez vus tout au long des Parties 1 et 2. Les tests unitaires valident la logique de transformation en utilisant des entrées mockées et statiques :
unit_tests: - name: test_discount_calculation model: mrt__finance__orders given: - input: ref('base__shopify__orders') rows: - {order_id: 1, subtotal: 100, discount_code: "SAVE20"} expect: rows: - {order_id: 1, discount_amount: 20, final_total: 80}Quand ils s’exécutent : Pendant la phase de build, avant la matérialisation.
Ce qu’ils détectent : Les bugs de logique, les cas limites, les régressions dans le code de transformation.
Caractéristique clé : Ils ne touchent jamais vos données réelles du warehouse.
Tests génériques
Les tests génériques sont des assertions paramétrées définies en YAML. dbt est livré avec quatre tests intégrés :
models: - name: mrt__core__customers columns: - name: customer_id data_tests: - unique - not_null - name: customer_status data_tests: - accepted_values: values: ['active', 'churned', 'pending'] - name: account_manager_id data_tests: - relationships: to: ref('mrt__hr__employees') field: employee_idQuand ils s’exécutent : Après la matérialisation, sur les données réelles du warehouse.
Ce qu’ils détectent : Les problèmes d’intégrité des données—doublons, nulls, valeurs invalides, clés étrangères cassées.
Caractéristique clé : Ils valident les données, pas la logique.
Tests singuliers
Les tests singuliers sont des requêtes SQL personnalisées dans le dossier tests/. Un test réussit si la requête retourne zéro ligne :
-- tests/assert_no_orphaned_orders.sql-- Les commandes doivent toujours avoir un client valideselect o.order_idfrom {{ ref('mrt__finance__orders') }} oleft join {{ ref('mrt__core__customers') }} c on o.customer_id = c.customer_idwhere c.customer_id is nullQuand les utiliser : Règles métier complexes qui ne rentrent pas dans les paramètres des tests génériques.
Tests de données : Le terme générique
“Tests de données” désigne à la fois les tests génériques et singuliers—tout ce qui s’exécute sur les données réelles du warehouse via dbt test. La distinction clé avec les tests unitaires :
| Aspect | Tests unitaires | Tests de données |
|---|---|---|
| Données d’entrée | Mockées, statiques | Données réelles du warehouse |
| Testent | La correction de la logique | La qualité des données |
| Quand exécuter | CI/développement | Chaque exécution de pipeline |
| Coût warehouse | Minimal (avec --empty) | Proportionnel à la taille des données |
Packages de test externes
L’écosystème dbt offre des packages puissants qui étendent les capacités de test natives.
dbt-expectations
Inspiré de Great Expectations, ce package fournit plus de 60 types de tests organisés par catégorie :
# Installationpackages: - package: calogica/dbt_expectations version: ">=0.10.0"Tests au niveau table :
models: - name: mrt__finance__orders data_tests: - dbt_expectations.expect_table_row_count_to_be_between: min_value: 1000 max_value: 1000000 - dbt_expectations.expect_table_row_count_to_equal_other_table: compare_model: ref('base__shopify__orders')Tests au niveau colonne :
columns: - name: email data_tests: - dbt_expectations.expect_column_values_to_match_regex: regex: "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" - name: order_value data_tests: - dbt_expectations.expect_column_values_to_be_between: min_value: 0 max_value: 100000 - name: order_date data_tests: - dbt_expectations.expect_column_values_to_be_of_type: column_type: dateTests de distribution :
columns: - name: order_value data_tests: - dbt_expectations.expect_column_mean_to_be_between: min_value: 50 max_value: 200 - dbt_expectations.expect_column_proportion_of_unique_values_to_be_between: min_value: 0.8Idéal pour : Des assertions complètes et explicites quand vous savez exactement quoi vérifier.
dbt-utils
Le package utilitaire officiel de dbt Labs inclut des macros de test éprouvées :
packages: - package: dbt-labs/dbt_utils version: ">=1.0.0"Tests clés :
models: - name: mrt__finance__orders data_tests: # Comparaison du nombre de lignes - dbt_utils.equal_rowcount: compare_model: ref('base__shopify__orders')
# Fraîcheur des données - dbt_utils.recency: datepart: day field: created_at interval: 1
# Plages de dates non chevauchantes - dbt_utils.mutually_exclusive_ranges: lower_bound_column: valid_from upper_bound_column: valid_to partition_by: customer_id
columns: - name: revenue data_tests: # Expression SQL arbitraire - dbt_utils.expression_is_true: expression: ">= 0"Idéal pour : Des tests simples et fiables qui fonctionnent sur tous les warehouses. Faible charge de maintenance.
Elementary
Elementary adopte une approche différente : détection d’anomalies basée sur le ML sans seuils codés en dur.
packages: - package: elementary-data/elementary version: ">=0.14.0"Au lieu de spécifier “le nombre de lignes doit être entre X et Y”, Elementary apprend ce qui est normal et alerte sur les déviations :
models: - name: mrt__finance__orders data_tests: # Alerte si le nombre de lignes dévie significativement du pattern historique - elementary.volume_anomalies: timestamp_column: created_at training_period: period: day count: 30
# Alerte si les données arrivent en retard - elementary.freshness_anomalies: timestamp_column: created_at
# Surveille les métriques au niveau colonne (nulls, cardinalité, etc.) - elementary.column_anomalies: column_name: order_value
# Détecte les changements de schéma - elementary.schema_changesComment ça marche : Elementary calcule un Z-score (combien d’écarts-types une métrique est de sa moyenne historique). Pas besoin de deviner les seuils.
Idéal pour : Détecter les “inconnus inconnus”, les anomalies pour lesquelles vous n’auriez pas pensé à écrire des tests explicites.
dbt-audit-helper
Conçu spécifiquement pour la validation des migrations et du refactoring :
packages: - package: dbt-labs/audit_helper version: ">=0.9.0"-- analyses/compare_legacy_to_new.sql{% set old_query %} select * from {{ ref('legacy_customers') }}{% endset %}
{% set new_query %} select * from {{ ref('mrt__core__customers') }}{% endset %}
{{ audit_helper.compare_queries( a_query=old_query, b_query=new_query, primary_key='customer_id') }}La sortie montre exactement quelles lignes diffèrent et comment :
| in_a | in_b | count ||------|------|-------|| true | true | 9950 | -- Lignes correspondantes| true | false| 30 | -- Seulement dans legacy| false| true | 20 | -- Seulement dans nouveauPour BigQuery spécifiquement, utilisez la comparaison basée sur le hash pour de meilleures performances :
{{ audit_helper.quick_are_queries_identical( a_query=old_query, b_query=new_query) }}Idéal pour : Valider que les modèles refactorisés produisent des résultats identiques aux requêtes legacy.
Résumé de comparaison des packages
| Package | Idéal pour | Effort de setup | Maintenance | BigQuery |
|---|---|---|---|---|
| dbt-expectations | Assertions explicites et complètes | Moyen | Moyenne (beaucoup d’options) | Support complet |
| dbt-utils | Tests standards simples et fiables | Faible | Faible | Support complet |
| Elementary | Détection d’anomalies, observabilité | Moyen | Moyenne | Support complet |
| dbt-audit-helper | Validation de migration | Faible | Faible | Support complet |
Le cadre de décision
Maintenant la partie pratique : comment décider quelle approche de test utiliser ?
Question 1 : Que testez-vous ?
| Ce que vous testez | Approche recommandée | Pourquoi |
|---|---|---|
| Correction de la logique de transformation | Tests unitaires | Les entrées mockées isolent la logique des problèmes de données |
| Intégrité de clé primaire | Tests génériques (unique + not_null) | Simple, intégré, s’exécute sur les données réelles |
| Intégrité référentielle | Tests génériques (relationships) | Validation de clé étrangère intégrée |
| SLAs de fraîcheur des données | dbt-utils recency OU Elementary | Seuil explicite vs. détection adaptative |
| Anomalies de volume | Elementary (volume_anomalies) | Pas besoin de deviner les seuils |
| Dérive de schéma | Elementary (schema_changes) | Surveillance automatique |
| Règles métier complexes | Tests singuliers | Flexibilité SQL complète |
| Validation regex/pattern | Tests unitaires OU dbt-expectations | Test de logique vs. validation de données |
| Précision de migration | dbt-audit-helper | Comparaison ligne par ligne |
| Distribution statistique | dbt-expectations | Vérifications de moyenne, médiane, percentile |
Question 2 : Quand voulez-vous détecter les problèmes ?
| Timing | Type de test | Justification |
|---|---|---|
| Avant que les mauvaises données n’entrent dans le warehouse | Tests unitaires | Détecter les bugs de logique en CI, avant le merge |
| Après transformation, avant l’aval | Tests de données | Bloquer les modèles échoués, empêcher la propagation |
| En continu en production | Elementary | Surveillance continue des anomalies |
| Pendant le développement/refactoring | dbt-audit-helper | Valider les changements par rapport à une baseline |
Question 3 : Quelle est votre tolérance coût/complexité ?
| Approche | Effort de setup | Coût d’exécution | Maintenance |
|---|---|---|---|
| Tests unitaires uniquement | Moyen | Faible (données mockées) | Faible |
| Tests génériques uniquement | Faible | Moyen (scans de tables) | Faible |
| dbt-expectations | Moyen | Moyen | Moyenne (beaucoup d’options de test) |
| Elementary | Élevé (nécessite configuration) | Plus élevé (requêtes historiques) | Moyenne |
| Stack complet | Élevé | Le plus élevé | Continue |
Arbre de décision
Le problème concerne-t-il la LOGIQUE ou les DONNÉES ?├── LOGIQUE (calcul, transformation, cas limite)│ └── → Test unitaire│└── DONNÉES (intégrité, qualité, fraîcheur) │ ├── Connaissez-vous le seuil/règle exact(e) ? │ ├── Oui, règle simple (unique, not null) │ │ └── → Test générique │ ├── Oui, règle complexe │ │ └── → Test singulier ou dbt-expectations │ └── Non, veut une détection adaptative │ └── → Elementary │ └── Validez-vous une migration ? └── → dbt-audit-helperLa pyramide de tests dbt
Comme la pyramide de tests logiciels, les projets dbt bénéficient d’une approche en couches :
/\ / \ Data Diffs / \ (dbt-audit-helper) /______\ Dev uniquement, utiliser avec parcimonie / \ / Anomaly \ Elementary / Detection \ Tables clés uniquement /______________\ / \ / Unit Tests \ 5-10% des modèles / \ Logique complexe uniquement /______________________\ / \ / Data Tests \ Large couverture /____________________________\ Chaque PK, FKs critiquesDistribution recommandée
Tests unitaires (~5-10% des modèles) : Concentrez-vous sur les modèles avec une logique complexe :
- Calculs avec plusieurs branches
- Fonctions de fenêtrage
- Règles métier personnalisées
- Tout ce qui a causé des bugs auparavant
Tests de données génériques (large couverture) :
- Clés primaires :
unique+not_nullsur chaque table - Clés étrangères :
relationshipssur les jointures critiques - Enums :
accepted_valuessur les colonnes status/type
dbt-expectations (ciblé) :
- Patterns regex pour emails, URLs, codes
- Plages numériques pour les valeurs avec des bornes connues
- Comparaisons de nombre de lignes pour les transformations critiques
Elementary (tables métier clés) :
- Surveillance de volume sur les tables de faits
- Fraîcheur sur les modèles proches des sources
- Anomalies de colonnes sur les données financières
dbt-audit-helper (développement uniquement) :
- Projets de refactoring majeurs
- Migration depuis des requêtes planifiées legacy
- Jamais dans les pipelines de production
Anti-patterns à éviter
Tester unitairement tout : Rendements décroissants. Concentrez-vous sur la logique complexe, pas les colonnes passthrough simples.
Ne tester que les chemins heureux : Vos tests unitaires devraient inclure les nulls, les chaînes vides, les valeurs limites et les cas limites.
Coder en dur des seuils qui dérivent : Si vous écrivez expect_table_row_count_to_be_between(min=1000, max=2000), vous devrez le mettre à jour au fur et à mesure que les données augmentent. Envisagez Elementary pour des seuils adaptatifs.
Tester les fonctions du warehouse : Ne testez pas unitairement SUM() ou DATE_TRUNC(). BigQuery les teste extensivement. Testez votre propre logique.
Implications de performance et de coût
Quand les tests s’exécutent
| Type de test | Déclencheur typique | Coût BigQuery |
|---|---|---|
| Tests unitaires | CI uniquement | Minimal (utiliser --empty) |
| Tests génériques | Chaque dbt build | Scans par table |
| dbt-expectations | Chaque dbt build | Scans par table |
| Elementary | Chaque exécution + entraînement | Requêtes historiques (plus élevé) |
| dbt-audit-helper | Dev/CI uniquement | Scans de tables complètes (le plus élevé) |
Optimiser pour BigQuery
Tests unitaires : Utilisez toujours le flag --empty en CI :
dbt run --select +test_type:unit --emptydbt test --select test_type:unitTests génériques : Envisagez des filtres de partition pour les grandes tables. Créez un wrapper de test personnalisé :
-- macros/partition_aware_unique.sql{% test partition_aware_unique(model, column_name, partition_column, lookback_days=7) %}select {{ column_name }}from {{ model }}where {{ partition_column }} >= date_sub(current_date(), interval {{ lookback_days }} day)group by 1having count(*) > 1{% endtest %}Elementary : Configurez des périodes d’entraînement appropriées pour équilibrer précision et coût :
- elementary.volume_anomalies: training_period: period: day count: 14 # 2 semaines, pas 90 joursdbt-audit-helper : Utilisez l’échantillonnage pour les grandes tables :
{{ audit_helper.compare_queries( a_query=old_query, b_query=new_query, primary_key='customer_id', summarize=true -- Juste les comptages, pas le diff complet) }}Exclure les tests de la production
Les tests unitaires n’apportent aucune valeur en production (les entrées sont mockées). Excluez-les :
# Déploiement productiondbt build --exclude-resource-type unit_testOu utilisez des variables d’environnement :
export DBT_EXCLUDE_RESOURCE_TYPES=unit_testdbt buildPour une exécution sélective des tests par environnement, utilisez dbt_project.yml :
tests: my_project: +enabled: "{{ target.name != 'prod' or var('run_all_tests', false) }}"Construire une stratégie de test
Pour les nouveaux projets
Commencez simple et étendez :
- Semaine 1 : Ajoutez
unique+not_nullà toutes les clés primaires - Semaine 2 : Ajoutez des tests unitaires pour vos 3 modèles les plus complexes
- Semaine 3 : Ajoutez des tests
relationshipspour les clés étrangères critiques - Mois 2 : Évaluez Elementary pour les tables métier clés
- En continu : Ajoutez des tests unitaires quand des bugs sont découverts (prévention de régression)
Pour les projets existants
Auditez et priorisez :
- Inventaire : Quels modèles ont des tests ? Lesquels n’en ont aucun ?
- Évaluation des risques : Quels modèles non testés sont les plus critiques ?
- Quick wins : Ajoutez des tests génériques à toutes les clés primaires (faible effort, haute valeur)
- Candidats aux tests unitaires : Modèles avec logique complexe, bugs récents, ou refactors à venir
- Observabilité : Envisagez Elementary pour les tables avec un historique de problèmes de qualité de données
Pour les migrations (Requêtes planifiées → dbt)
dbt-audit-helper est essentiel :
-- Étape 1: Créer la comparaison{% set legacy_query %}select * from `project.dataset.legacy_scheduled_query_output`{% endset %}
{% set dbt_query %}select * from {{ ref('mrt__finance__new_model') }}{% endset %}
-- Étape 2: Exécuter la comparaison{{ audit_helper.compare_all_columns( a_query=legacy_query, b_query=dbt_query, primary_key='id') }}Workflow :
- Exécutez la comparaison, identifiez toutes les différences
- Investiguer : Les différences sont-elles des bugs ou des améliorations intentionnelles ?
- Testez unitairement la nouvelle logique (pas seulement la parité)
- Documentez les changements intentionnels
- Validez avec les parties prenantes
Le pattern “Test on Failure”
Quand un bug est découvert :
- Écrivez le test unitaire d’abord — reproduisez le bug avec des données mockées
- Vérifiez qu’il échoue — confirme que vous avez capturé le problème
- Corrigez le modèle
- Vérifiez que le test passe
- Documentez le scénario dans la description du test
unit_tests: - name: test_discount_negative_quantity_bug model: mrt__finance__orders description: | Test de régression pour BUG-1234: Les quantités négatives causaient des calculs de remise retournant des valeurs négatives. Corrigé 2024-06-15. given: - input: ref('base__shopify__orders') rows: - {order_id: 1, quantity: -1, unit_price: 100, discount_rate: 0.1} expect: rows: - {order_id: 1, discount_amount: 0} # Devrait être 0, pas -10Cela construit organiquement une suite de tests de régression, concentrée sur les modes de défaillance réels.
Leçons clés
- L’ajout manuel de tests ne passe pas à l’échelle : Automatisez où c’est possible (Elementary) et priorisez impitoyablement
- Les inconnus inconnus comptent : Les tests explicites ne détectent que les problèmes que vous anticipez ; la détection d’anomalies détecte le reste
- La propriété des tests est critique : Utilisez
meta.ownerpour vous assurer que les tests sont maintenus :
models: - name: mrt__finance__orders config: meta: owner: "finance-data-team" slack_channel: "#data-finance-alerts"Référence rapide : Aide-mémoire de sélection de tests
| Scénario | Utilisez ceci | Exemple |
|---|---|---|
| Calcul complexe | Test unitaire | Revenu avec remises, taxes, frais |
| Vérification de clé primaire | unique + not_null | Colonne ID de chaque modèle |
| Vérification de clé étrangère | relationships | order.customer_id → customer.id |
| Validation d’enum | accepted_values | status dans [‘active’, ‘pending’, ‘closed’] |
| Pattern regex | dbt_expectations.expect_column_values_to_match_regex | Format email |
| Bornes numériques | dbt_expectations.expect_column_values_to_be_between | order_value 0-100000 |
| Stabilité du nombre de lignes | Elementary volume_anomalies | Tables de faits |
| Fraîcheur des données | dbt_utils.recency ou Elementary | Staging proche des sources |
| Surveillance de schéma | Elementary schema_changes | Tous les modèles |
| Règle métier personnalisée | Test singulier | ”Pas de commandes sans lignes” |
| Validation de migration | dbt-audit-helper | Comparaison requête planifiée → dbt |
Ceci conclut la série sur les tests unitaires dbt. Pour toute question ou feedback, n’hésitez pas à me contacter.