Adrienne Vermorel

Stratégie de tests dbt : un cadre pour chaque projet

La plupart des équipes data tombent dans l’un de ces deux pièges. Le premier groupe écrit des tests pour tout, générant tellement d’alertes que l’équipe apprend à les ignorer. Les vrais problèmes passent inaperçus. Le second groupe n’écrit presque aucun test, comptant sur les utilisateurs pour signaler les problèmes une fois les dashboards cassés. Les deux approches échouent pour la même raison : elles traitent les tests comme une case à cocher plutôt que comme une stratégie.

Après avoir audité des dizaines de projets dbt, un pattern émerge. Les équipes avec des données fiables n’ont pas nécessairement plus de tests ; elles ont des tests mieux placés avec une propriété claire et une sévérité appropriée. Cet article synthétise le paysage complet des outils de test dbt en 2026 et fournit un cadre opinioné qui s’adapte de votre premier projet aux pipelines de niveau entreprise.

Comprendre la taxonomie des tests

Avant de plonger dans l’implémentation, ça vaut la peine de clarifier une terminologie qui prête souvent à confusion. dbt offre désormais trois mécanismes distincts de validation, chacun servant un objectif différent.

Les tests de données valident la qualité de vos données réelles après la matérialisation des modèles. Quand vous ajoutez un test unique à une colonne, dbt interroge la table construite et vérifie si des doublons existent. Ces tests répondent à : « Mes données sont-elles correctes ? »

Les tests unitaires, introduits dans dbt 1.8, valident votre logique de transformation avant la matérialisation. Vous fournissez des entrées fictives et des sorties attendues, et dbt vérifie que votre SQL produit les bons résultats sans toucher aux données de production. Ces tests répondent à : « Ma logique est-elle correcte ? »

Les contrats de modèle imposent des garanties de schéma au moment du build. Quand vous activez un contrat, dbt refuse de construire le modèle si la sortie ne correspond pas aux noms de colonnes et types de données déclarés. Les contrats répondent à : « Mon schéma est-il stable ? »

La matrice de décision est simple : utilisez les tests de données pour la validation de qualité sur chaque modèle, les tests unitaires pour la logique métier complexe que vous devez vérifier, et les contrats pour les modèles exposés publiquement où la stabilité du schéma importe aux consommateurs en aval.

Au sein des tests de données, vous rencontrerez deux variantes. Les tests génériques sont réutilisables et configurés en YAML (les quatre tests intégrés plus tout ce qui vient des packages). Les tests singuliers sont des fichiers SQL ponctuels dans votre répertoire tests/ qui retournent zéro ligne en cas de succès. Utilisez les tests singuliers quand la logique de validation est unique à un modèle ou nécessite une logique cross-table complexe que les tests génériques ne peuvent pas exprimer.

Tests intégrés de dbt : les fondations

dbt est livré avec quatre tests génériques qui forment la colonne vertébrale de toute stratégie de test. Bien utilisés, ils détectent la majorité des problèmes d’intégrité des données avant qu’ils ne se propagent en aval.

Le test unique valide qu’aucune valeur dupliquée n’existe dans une colonne. Appliquez-le à chaque clé primaire sans exception. Le test not_null garantit que les champs obligatoires sont remplis ; associez-le à unique sur les clés primaires. Le test accepted_values contraint les colonnes catégorielles aux valeurs attendues, particulièrement utile pour les champs de statut où des valeurs inattendues des systèmes amont cassent la logique en aval. Le test relationships valide l’intégrité référentielle, en s’assurant que les valeurs de clés étrangères existent dans leur table parente.

models:
- name: mrt__sales__orders
columns:
- name: order_id
data_tests:
- unique
- not_null
- name: customer_id
data_tests:
- not_null
- relationships:
to: ref('mrt__sales__customers')
field: customer_id
- name: status
data_tests:
- accepted_values:
values: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled']

Cette base (unique et not_null sur chaque clé primaire, relationships sur les clés étrangères critiques) devrait être non négociable. Elle détecte les explosions de jointures dues aux clés dupliquées, les références cassées dues aux clés étrangères orphelines, et les nulls inattendus qui causent des échecs en aval.

Quand les tests intégrés ne suffisent pas, les tests génériques personnalisés vous permettent de créer des validations réutilisables et paramétrées pour votre organisation. Créez-les comme des macros dans tests/generic/ en utilisant la syntaxe du bloc {% test %} :

-- tests/generic/test_is_positive.sql
{% test is_positive(model, column_name) %}
SELECT *
FROM {{ model }}
WHERE {{ column_name }} < 0
{% endtest %}

Une fois défini, appliquez-le comme n’importe quel test générique :

columns:
- name: quantity
data_tests:
- is_positive

Tests personnalisés courants à implémenter : is_positive pour les champs numériques qui ne devraient jamais être négatifs, not_empty_string pour les champs texte où les chaînes vides sont invalides, date_not_in_future pour les timestamps qui ne devraient pas dépasser la date actuelle, et valid_email_format pour une validation basique d’email.

Tests unitaires natifs : tester la logique, pas seulement les données

Les tests unitaires natifs, introduits dans dbt 1.8, vous permettent de valider que vos transformations SQL produisent des résultats corrects contre des entrées contrôlées. Cela permet un véritable développement piloté par les tests pour la data.

Les tests unitaires sont définis en YAML, typiquement dans vos fichiers de schéma aux côtés du modèle qu’ils testent :

unit_tests:
- name: test_customer_lifetime_value_calculation
description: "Vérifier que le calcul LTV gère correctement les cas limites"
model: mrt__sales__customers
given:
- input: ref('base__shopify__orders')
rows:
- {customer_id: 1, order_total: 100.00, order_date: '2024-01-15'}
- {customer_id: 1, order_total: 250.00, order_date: '2024-02-20'}
- {customer_id: 2, order_total: 0.00, order_date: '2024-01-10'}
- input: ref('base__shopify__customers')
rows:
- {customer_id: 1, created_at: '2024-01-01'}
- {customer_id: 2, created_at: '2024-01-01'}
expect:
rows:
- {customer_id: 1, lifetime_value: 350.00, order_count: 2}
- {customer_id: 2, lifetime_value: 0.00, order_count: 1}

Le bloc given simule vos refs d’entrée avec des données statiques. Le bloc expect définit ce que votre modèle devrait produire. dbt construit le modèle en utilisant uniquement les entrées simulées et compare la sortie réelle à la sortie attendue.

Tous les modèles n’ont pas besoin de tests unitaires. Ce sont des outils exigeants mais précis, à réserver à des scénarios spécifiques :

Parsing de chaînes complexes ou regex : si vous extrayez des domaines d’emails, parsez des paramètres UTM d’URLs, ou nettoyez des champs texte désordonnés, les tests unitaires vérifient que votre regex gère les cas limites.

Calculs de dates avec cas limites : les mappings d’années fiscales, les calculs de jours ouvrés et les conversions de fuseaux horaires ont tous des cas limites faciles à rater. Les tests unitaires avec des dates spécifiques les détectent avant la production.

Logique CASE WHEN multi-branches : quand la valeur d’une colonne dépend de multiples conditions, les tests unitaires documentent le comportement attendu pour chaque branche et détectent les régressions quand la logique change.

Fonctions de fenêtrage : les classements, totaux cumulés et calculs lag/lead sont notoirement délicats. Simuler un petit jeu de données vous permet de vérifier que la fenêtre se comporte correctement.

Le consensus dans la communauté dbt est qu’environ 1% des colonnes justifient des tests unitaires, spécifiquement celles avec des calculs complexes à fort impact métier. N’essayez pas de tout tester unitairement ; la charge de maintenance n’en vaut pas la peine.

Pour les modèles incrémentaux, les tests unitaires deviennent encore plus précieux. Vous pouvez surcharger la macro is_incremental() et simuler this (l’état actuel de la table) pour vérifier la logique de merge :

unit_tests:
- name: test_incremental_deduplication
model: int__events_deduplicated
overrides:
macros:
is_incremental: true
given:
- input: ref('base__segment__events')
rows:
- {event_id: 1, event_time: '2024-03-01 10:00:00', value: 100}
- {event_id: 1, event_time: '2024-03-01 10:00:00', value: 150} # doublon
- input: this
rows:
- {event_id: 0, event_time: '2024-02-28 09:00:00', value: 50} # existant
expect:
rows:
- {event_id: 1, event_time: '2024-03-01 10:00:00', value: 150} # dernière valeur gagne

Le principal point de friction est la « comptabilité YAML » (créer manuellement des fixtures pour des modèles avec beaucoup de colonnes). L’IA peut aider à générer ces fixtures. Pour les exécutions en production, excluez entièrement les tests unitaires avec dbt build --exclude-resource-type unit_test ; ce sont des outils de développement et CI uniquement.

Contrats de modèle : stabilité du schéma pour les consommateurs

Les contrats de modèle servent un objectif fondamentalement différent des tests. Alors que les tests valident après coup, les contrats empêchent les builds quand le schéma de sortie ne correspond pas à votre déclaration. Cela fournit des garanties aux consommateurs en aval (outils BI, reverse ETL, équipes externes) que la structure de votre modèle ne changera pas de façon inattendue.

models:
- name: mrt__sales__customers
config:
contract:
enforced: true
columns:
- name: customer_id
data_type: int64
constraints:
- type: not_null
- type: primary_key
- name: email
data_type: string
- name: created_at
data_type: timestamp
- name: lifetime_value
data_type: numeric

Quand contract.enforced est vrai, dbt valide au moment du build que votre modèle produit exactement ces colonnes avec exactement ces types. Si vous ajoutez une colonne à votre SQL mais oubliez de l’ajouter au contrat, le build échoue. Si une colonne revient comme float64 au lieu de int64, le build échoue.

Utilisez les contrats sur les modèles publics/exposés uniquement : les marts que les outils BI interrogent, les tables qui alimentent le reverse ETL, les datasets partagés avec des équipes externes. Ne vous embêtez pas avec les contrats sur les modèles base ou intermediate ; ils ajoutent de la charge sans bénéfice pour les transformations internes.

Combinés avec les versions de modèle, les contrats permettent une évolution de schéma non-cassante. Vous pouvez introduire une v2 d’un modèle avec un nouveau schéma tout en maintenant la v1 pour les consommateurs existants, leur donnant le temps de migrer.

Une mise en garde : l’application des contraintes varie selon le warehouse. Snowflake et BigQuery traitent souvent les contraintes comme des métadonnées uniquement. Ils n’empêcheront pas les violations de contraintes au moment de l’insertion. Postgres et Redshift peuvent réellement les appliquer. La contrainte not_null est la plus fiablement appliquée sur toutes les plateformes.

L’écosystème des packages de test

Les tests intégrés couvrent les bases, mais les packages étendent considérablement les capacités de dbt.

dbt-utils est essentiel pour tout projet. Maintenu par dbt Labs, il propose des tests qui mériteraient d’être intégrés nativement :

  • equal_rowcount : comparer le nombre de lignes entre deux relations (inestimable pendant le refactoring)
  • expression_is_true : tester n’importe quelle expression SQL que vous pouvez écrire
  • recency : vérifier la fraîcheur des données sans les vérifications de fraîcheur source
  • unique_combination_of_columns : tester l’unicité des clés composites
models:
- name: mrt__sales__order_items
data_tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns:
- order_id
- line_item_id
- dbt_utils.expression_is_true:
expression: "quantity > 0"
- dbt_utils.recency:
datepart: day
field: created_at
interval: 1

dbt-expectations porte plus de 50 tests de la bibliothèque Python Great Expectations, permettant une validation statistique et basée sur des patterns :

  • expect_column_values_to_be_between : validation de plage avec min/max
  • expect_column_mean_to_be_between : vérifications de distribution statistique
  • expect_column_values_to_match_regex : correspondance de patterns
  • expect_table_row_count_to_equal_other_table : validation cross-table

La fonctionnalité clé est le paramètre row_condition, permettant des tests conditionnels :

- dbt_expectations.expect_column_values_to_not_be_null:
column_name: shipping_date
row_condition: "status = 'shipped'" # tester uniquement les commandes expédiées

À noter qu’à la suite d’un fork fin 2024, le package est maintenant maintenu par Metaplane sous le nom dbt_expectations.

Elementary adopte une approche fondamentalement différente : la détection d’anomalies au lieu de seuils statiques. Plutôt que de définir « échouer si plus de 100 nulls », Elementary apprend les patterns de vos données historiques et alerte quand les métriques dévient au-delà des plages attendues.

models:
- name: mrt__sales__orders
data_tests:
- elementary.volume_anomalies:
timestamp_column: created_at
where: "status != 'cancelled'"
- elementary.freshness_anomalies:
timestamp_column: updated_at
- elementary.column_anomalies:
column_name: order_total
anomaly_sensitivity: 3 # seuil z-score

Elementary fournit également la détection schema_changes, alertant sur les ajouts, suppressions ou changements de type de colonnes inattendus. Il inclut un CLI qui génère des rapports d’observabilité. Pour les équipes souffrant de fatigue d’alertes des tests basés sur des seuils, l’approche dynamique d’Elementary réduit le bruit tout en détectant les problèmes que les tests statiques manquent.

dbt-audit-helper est essentiel pendant les migrations et le refactoring. Sa macro compare_queries effectue une comparaison ligne par ligne entre deux requêtes, classant les résultats comme correspondance parfaite, modifié ou manquant :

{% set old_query %}
SELECT * FROM {{ ref('legacy_customers') }}
{% endset %}
{% set new_query %}
SELECT * FROM {{ ref('mrt__sales__customers') }}
{% endset %}
{{ audit_helper.compare_queries(
a_query=old_query,
b_query=new_query,
primary_key='customer_id'
) }}

Utilisez-le lors de la migration de SQL legacy vers dbt ou pour valider qu’une logique refactorisée produit des résultats identiques.

Stratégie de test couche par couche

Le cadre de test le plus efficace applique différents tests à chaque couche de votre DAG. L’intensité des tests devrait augmenter vers les bords : les sources où les problèmes entrent dans votre pipeline, et les marts où les problèmes sortent vers les consommateurs.

À la couche sources, testez uniquement ce qui est corrigeable en amont. Appliquez des vérifications de fraîcheur source avec une sévérité appropriée (error pour les sources critiques métier, warn pour les informatives). Testez unique et not_null sur les clés primaires uniquement si les doublons ou nulls sont réellement supprimables dans le système source. S’ils ne sont pas corrigeables à la source, supprimez le test et gérez le problème dans les modèles base à la place.

sources:
- name: salesforce
freshness:
warn_after: {count: 12, period: hour}
error_after: {count: 24, period: hour}
tables:
- name: accounts
loaded_at_field: systemmodstamp
columns:
- name: id
data_tests:
- unique
- not_null

À la couche base, établissez votre « contrat propre » (la promesse que les modèles en aval reçoivent des données bien typées, dédupliquées et avec les nulls gérés). Ne testez pas votre nettoyage : si votre modèle base filtre les nulls, ajouter un test not_null est redondant et ajoute du temps d’exécution. À la place, testez les anomalies spécifiques au métier : valeurs en dehors des plages acceptables, valeurs catégorielles inattendues, anomalies de volume.

models:
- name: base__shopify__orders
columns:
- name: order_id
data_tests:
- unique
- not_null
- name: order_total
data_tests:
- dbt_utils.expression_is_true:
expression: ">= 0"
- name: currency
data_tests:
- accepted_values:
values: ['USD', 'EUR', 'GBP', 'CAD']

À la couche intermediate, testez les conséquences des jointures et agrégations. Appliquez des tests de clé primaire à tout modèle où vous avez changé la granularité. Si vous avez agrégé des lignes de commande vers les commandes, l’order_id devrait maintenant être unique. Utilisez des tests relationships où vous avez joint des tables. Considérez des tests d’expression pour vérifier la cohérence des résultats d’agrégation.

À la couche marts, investissez le plus. C’est là que les tests unitaires gagnent leur place : règles de segmentation client, calculs financiers, logique de prévision. Appliquez des tests génériques complets sur toutes les colonnes exposées. Activez les contrats de modèle pour la stabilité du schéma. Concentrez les tests sur les colonnes calculées nouvelles ; ne re-testez pas les champs passthrough qui ont déjà été validés en amont.

Gestion de la sévérité et la philosophie de la vitre cassée

La distinction entre les sévérités warn et error semble mineure mais affecte considérablement la relation de votre équipe aux tests. Une error bloque l’exécution ; un warn enregistre un message et continue. Le pattern qui fonctionne : error en CI, warn en production.

data_tests:
- unique:
config:
severity: "{{ 'error' if target.name == 'ci' else 'warn' }}"

Pour plus de nuance, utilisez des seuils de sévérité conditionnels :

data_tests:
- not_null:
config:
severity: error
error_if: ">1000"
warn_if: ">10"

Cela crée un système à deux niveaux : alertes précoces pour investigation au-delà de 10 nulls, échecs bloquants quand la situation devient critique à 1000.

La théorie de la vitre cassée s’applique directement aux tests de données : ne tolérez jamais des tests qui échouent chroniquement. Les équipes qui acceptent quelques échecs permanents en acceptent rapidement plus, jusqu’à ce que toute la suite de tests devienne du bruit de fond. Quand un test échoue, vous avez quatre options :

  1. Corriger le problème sous-jacent dans les données ou la logique
  2. Mettre à jour les attentes du test si le seuil original était faux
  3. Taguer comme en cours d’investigation avec une deadline et un propriétaire
  4. Supprimer le test s’il n’apporte aucune valeur exploitable

Ce que vous ne pouvez pas faire, c’est le laisser échouer indéfiniment.

Le routage des alertes compte autant que la sévérité. Taguez les modèles avec la propriété et la criticité dans leurs meta :

models:
- name: mrt__finance__revenue
config:
meta:
owner: "finance-analytics"
criticality: "high"

Puis routez les alertes en conséquence : pagez l’astreinte pour les échecs de modèles critiques, Slack pour haute priorité, digest email quotidien pour moyen, revue hebdomadaire pour bas. Sans propriété claire, les échecs de tests deviennent le problème de tout le monde, autrement dit le problème de personne.

Patterns avancés

Tester les modèles incrémentaux demande une attention particulière car la correction dépend de l’état existant de la table. Définissez toujours une unique_key pour garantir l’idempotence. Sans elle, ré-exécuter le même modèle incrémental crée des doublons.

Pour les données arrivant en retard, implémentez une fenêtre de lookback :

{% if is_incremental() %}
WHERE event_time >= (
SELECT TIMESTAMP_SUB(MAX(event_time), INTERVAL {{ var('lookback_days', 3) }} DAY)
FROM {{ this }}
)
{% endif %}

Cela retraite les N derniers jours à chaque exécution, rattrapant les enregistrements arrivés après leur timestamp logique.

Les vérifications de fraîcheur source devraient s’exécuter au moins deux fois plus souvent que votre SLA le plus bas. Si vous promettez aux utilisateurs des données dans les 6 heures, vérifiez la fraîcheur au minimum toutes les 3 heures. Pour les grandes tables, ajoutez un filtre pour limiter le scan :

sources:
- name: events
tables:
- name: raw_events
loaded_at_field: _loaded_at
freshness:
warn_after: {count: 2, period: hour}
error_after: {count: 6, period: hour}
filter: "_loaded_at >= current_date - 1"

La détection de changement de schéma combine les contrats (application au build) avec le test schema_changes d’Elementary (alerte au runtime). Les contrats détectent les problèmes que vous définissez explicitement ; Elementary détecte les changements inattendus que vous n’avez pas anticipés. Ensemble, ils fournissent une gouvernance de schéma complète.

L’intégrité référentielle à grande échelle nécessite une optimisation de performance. Pour les tables avec des milliards de lignes, tester chaque relation de clé étrangère devient coûteux. Voici quelques stratégies :

  • Filtrer aux données récentes : WHERE created_at >= CURRENT_DATE - 30
  • Échantillonner les données : tester un sous-ensemble aléatoire plutôt que la table complète
  • Utiliser le package dbt_constraints pour créer des contraintes natives de base de données quand c’est supporté
  • Paralléliser avec des nombres de threads plus élevés

Tests vs Observabilité

Les tests et l’observabilité sont des approches complémentaires de la qualité des données. Les tests détectent les problèmes anticipés : des conditions que vous avez prévues et pour lesquelles vous avez écrit des règles. L’observabilité détecte les problèmes imprévus : des anomalies que vous n’auriez pas pu prédire.

Un scénario de production en panne illustre la différence. Les tests pourraient détecter « valeurs nulles dans customer_id » (un mode d’échec connu que vous testez). L’observabilité détecte « le nombre de lignes a chuté de 80% par rapport aux volumes typiques du mercredi » (une anomalie que vous n’avez pas explicitement testée).

Le chemin pratique de maturité :

  1. Commencez avec les tests génériques + dbt-utils : couvrez les clés primaires, clés étrangères, contraintes basiques
  2. Ajoutez dbt-expectations : quand vous avez besoin de validation statistique ou de tests conditionnels
  3. Ajoutez Elementary : une fois que votre suite de tests a mûri et que vous voulez de la détection d’anomalies
  4. Considérez les plateformes commerciales : Monte Carlo, Metaplane ou autres quand l’open-source atteint ses limites

Les équipes les plus matures mettent en place un « système auto-améliorant » : quand les outils d’observabilité détectent une anomalie, elles la convertissent en test dbt permanent. La couverture s’étend ainsi continuellement, basée sur des incidents réels plutôt que sur des spéculations.

Le cadre opinioné

Voici un chemin d’implémentation concret basé sur la maturité de votre projet.

Démarrage (0-50 modèles) :

  • Ajoutez unique et not_null à chaque clé primaire, sans exception
  • Ajoutez des tests relationships sur les clés étrangères critiques
  • Configurez la fraîcheur source sur toutes les sources d’ingestion
  • Utilisez dbt build pour exécuter les tests immédiatement après la construction de chaque modèle
  • Mettez tous les tests à severity: error initialement. Vous voulez connaître chaque échec

Maturation (50-200 modèles) :

  • Implémentez des tests spécifiques par couche basés sur la stratégie ci-dessus
  • Ajoutez dbt_project_evaluator pour appliquer des seuils de couverture
  • Mettez en place la Slim CI avec --select state:modified+ pour tester uniquement les modèles modifiés
  • Introduisez des seuils de sévérité conditionnels pour les tests bruyants
  • Établissez une propriété claire avec les tags meta.owner

Avancé (200+ modèles) :

  • Écrivez des tests unitaires natifs pour votre logique métier la plus complexe (ciblez ~1% des colonnes)
  • Déployez Elementary pour la détection d’anomalies
  • Activez les contrats de modèle sur tous les marts exposés publiquement
  • Construisez une classification automatique de sévérité basée sur l’impact en aval
  • Implémentez des alertes graduées : page, Slack, digest, revue hebdomadaire

Conclusion

Une stratégie de test dbt efficace ne consiste pas à maximiser la couverture. C’est une question de placement stratégique, de sévérité appropriée et de propriété claire. Testez intensivement aux bords de votre pipeline, là où les problèmes entrent (sources) et sortent (marts). Utilisez le bon outil pour chaque scénario : tests génériques pour l’intégrité, tests unitaires pour la logique, contrats pour la stabilité du schéma, observabilité pour les imprévus.

Les équipes avec la meilleure qualité de données partagent des traits communs. Elles ne normalisent jamais les tests qui échouent. Elles routent les alertes vers des propriétaires capables d’agir. Elles convertissent systématiquement les incidents en tests permanents. Et elles traitent la qualité des données comme une fonctionnalité à concevoir, pas comme un fardeau à supporter.

Commencez par les bases (unique et not_null sur chaque clé primaire) et construisez à partir de là. Ce cadre s’adapte aussi bien à un analytics engineer solo qu’aux plateformes de données d’entreprise. L’essentiel est de commencer avec intention et de maintenir la discipline au fil du développement du repo.