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_id

Quand 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 valide
select o.order_id
from {{ ref('mrt__finance__orders') }} o
left join {{ ref('mrt__core__customers') }} c on o.customer_id = c.customer_id
where c.customer_id is null

Quand 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 :

AspectTests unitairesTests de données
Données d’entréeMockées, statiquesDonnées réelles du warehouse
TestentLa correction de la logiqueLa qualité des données
Quand exécuterCI/développementChaque exécution de pipeline
Coût warehouseMinimal (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 :

Terminal window
# Installation
packages:
- 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: date

Tests 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.8

Idé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 :

Terminal window
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.

Terminal window
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_changes

Comment ç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 :

Terminal window
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 nouveau

Pour 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

PackageIdéal pourEffort de setupMaintenanceBigQuery
dbt-expectationsAssertions explicites et complètesMoyenMoyenne (beaucoup d’options)Support complet
dbt-utilsTests standards simples et fiablesFaibleFaibleSupport complet
ElementaryDétection d’anomalies, observabilitéMoyenMoyenneSupport complet
dbt-audit-helperValidation de migrationFaibleFaibleSupport 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 testezApproche recommandéePourquoi
Correction de la logique de transformationTests unitairesLes entrées mockées isolent la logique des problèmes de données
Intégrité de clé primaireTests génériques (unique + not_null)Simple, intégré, s’exécute sur les données réelles
Intégrité référentielleTests génériques (relationships)Validation de clé étrangère intégrée
SLAs de fraîcheur des donnéesdbt-utils recency OU ElementarySeuil explicite vs. détection adaptative
Anomalies de volumeElementary (volume_anomalies)Pas besoin de deviner les seuils
Dérive de schémaElementary (schema_changes)Surveillance automatique
Règles métier complexesTests singuliersFlexibilité SQL complète
Validation regex/patternTests unitaires OU dbt-expectationsTest de logique vs. validation de données
Précision de migrationdbt-audit-helperComparaison ligne par ligne
Distribution statistiquedbt-expectationsVérifications de moyenne, médiane, percentile

Question 2 : Quand voulez-vous détecter les problèmes ?

TimingType de testJustification
Avant que les mauvaises données n’entrent dans le warehouseTests unitairesDétecter les bugs de logique en CI, avant le merge
Après transformation, avant l’avalTests de donnéesBloquer les modèles échoués, empêcher la propagation
En continu en productionElementarySurveillance continue des anomalies
Pendant le développement/refactoringdbt-audit-helperValider les changements par rapport à une baseline

Question 3 : Quelle est votre tolérance coût/complexité ?

ApprocheEffort de setupCoût d’exécutionMaintenance
Tests unitaires uniquementMoyenFaible (données mockées)Faible
Tests génériques uniquementFaibleMoyen (scans de tables)Faible
dbt-expectationsMoyenMoyenMoyenne (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-helper

La 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 critiques

Distribution 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_null sur chaque table
  • Clés étrangères : relationships sur les jointures critiques
  • Enums : accepted_values sur 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 testDéclencheur typiqueCoût BigQuery
Tests unitairesCI uniquementMinimal (utiliser --empty)
Tests génériquesChaque dbt buildScans par table
dbt-expectationsChaque dbt buildScans par table
ElementaryChaque exécution + entraînementRequêtes historiques (plus élevé)
dbt-audit-helperDev/CI uniquementScans de tables complètes (le plus élevé)

Optimiser pour BigQuery

Tests unitaires : Utilisez toujours le flag --empty en CI :

Terminal window
dbt run --select +test_type:unit --empty
dbt test --select test_type:unit

Tests 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 1
having 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 jours

dbt-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 :

Terminal window
# Déploiement production
dbt build --exclude-resource-type unit_test

Ou utilisez des variables d’environnement :

Terminal window
export DBT_EXCLUDE_RESOURCE_TYPES=unit_test
dbt build

Pour une exécution sélective des tests par environnement, utilisez dbt_project.yml :

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 :

  1. Semaine 1 : Ajoutez unique + not_null à toutes les clés primaires
  2. Semaine 2 : Ajoutez des tests unitaires pour vos 3 modèles les plus complexes
  3. Semaine 3 : Ajoutez des tests relationships pour les clés étrangères critiques
  4. Mois 2 : Évaluez Elementary pour les tables métier clés
  5. En continu : Ajoutez des tests unitaires quand des bugs sont découverts (prévention de régression)

Pour les projets existants

Auditez et priorisez :

  1. Inventaire : Quels modèles ont des tests ? Lesquels n’en ont aucun ?
  2. Évaluation des risques : Quels modèles non testés sont les plus critiques ?
  3. Quick wins : Ajoutez des tests génériques à toutes les clés primaires (faible effort, haute valeur)
  4. Candidats aux tests unitaires : Modèles avec logique complexe, bugs récents, ou refactors à venir
  5. 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 :

  1. Exécutez la comparaison, identifiez toutes les différences
  2. Investiguer : Les différences sont-elles des bugs ou des améliorations intentionnelles ?
  3. Testez unitairement la nouvelle logique (pas seulement la parité)
  4. Documentez les changements intentionnels
  5. Validez avec les parties prenantes

Le pattern “Test on Failure”

Quand un bug est découvert :

  1. Écrivez le test unitaire d’abord — reproduisez le bug avec des données mockées
  2. Vérifiez qu’il échoue — confirme que vous avez capturé le problème
  3. Corrigez le modèle
  4. Vérifiez que le test passe
  5. 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 -10

Cela construit organiquement une suite de tests de régression, concentrée sur les modes de défaillance réels.

Leçons clés

  1. L’ajout manuel de tests ne passe pas à l’échelle : Automatisez où c’est possible (Elementary) et priorisez impitoyablement
  2. 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
  3. La propriété des tests est critique : Utilisez meta.owner pour 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énarioUtilisez ceciExemple
Calcul complexeTest unitaireRevenu avec remises, taxes, frais
Vérification de clé primaireunique + not_nullColonne ID de chaque modèle
Vérification de clé étrangèrerelationshipsorder.customer_id → customer.id
Validation d’enumaccepted_valuesstatus dans [‘active’, ‘pending’, ‘closed’]
Pattern regexdbt_expectations.expect_column_values_to_match_regexFormat email
Bornes numériquesdbt_expectations.expect_column_values_to_be_betweenorder_value 0-100000
Stabilité du nombre de lignesElementary volume_anomaliesTables de faits
Fraîcheur des donnéesdbt_utils.recency ou ElementaryStaging proche des sources
Surveillance de schémaElementary schema_changesTous les modèles
Règle métier personnaliséeTest singulier”Pas de commandes sans lignes”
Validation de migrationdbt-audit-helperComparaison 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.