J'ai audité un projet dbt de 200 modèles avec 4 tests au total. Quatre. Tous en not_null sur des colonnes au hasard. Le pipeline tournait depuis 8 mois, et personne n'avait remarqué qu'une table de fait avait 12% de lignes en double depuis une jointure mal formulée. Le jour où le CFO a vu des chiffres incohérents dans son dashboard, c'est remonté au CTO, et l'équipe data a passé une semaine à reverse-engineerer le bug. Un unique test sur la clé primaire aurait suffi. Voici les 10 tests que je pose sur chaque projet, avant même d'écrire le premier modèle de transformation.

Pourquoi tester avec dbt
Un test dbt n'est pas un test unitaire au sens logiciel. C'est une assertion sur les données : "cette colonne doit être unique", "cette colonne ne doit pas être nulle", "cette valeur doit exister dans l'autre table". dbt génère une requête SQL qui cherche les lignes qui violent l'assertion. Si la requête retourne 0 ligne, le test passe. Sinon, il échoue.
| Sans tests | Avec tests |
|---|---|
| Bug détecté par le métier (trop tard) | Bug détecté au dbt test (avant la prod) |
| "Pourquoi mes chiffres sont faux ?" | "Le test unique a échoué sur 47 lignes" |
| Debug en réactif, sous pression | Debug en proactif, en CI/CD |
| Confiance des utilisateurs dégradée | Confiance construite par la fiabilité |
Les 4 tests natifs de dbt — le minimum vital
dbt ships avec 4 tests génériques. Ils sont gratuits, intégrés, et devraient être sur chaque modèle de ton projet.
1. unique — pas de doublons sur la clé
Le test le plus important. Vérifie qu'une colonne ne contient pas de valeurs dupliquées. À poser systématiquement sur chaque clé primaire.
# models/silver/sales.yml
models:
- name: stg_sales
description: "Sales fact table - one row per order line"
columns:
- name: order_line_id
description: "Primary key"
tests:
- unique
-- Ce que dbt génère et exécute sur Snowflake :
SELECT order_line_id, COUNT(*) as n
FROM SILVER_DEV.SALES.stg_sales
GROUP BY order_line_id
HAVING COUNT(*) > 1
-- Si 0 lignes retournées : test OK
-- Si 47 lignes retournées : test échoue avec 47 failures
Quand ça sauve la vie : une jointure qui produit des doublons, un UNION qui dédouble les lignes, un CDC qui rejoue un snapshot. C'est le bug le plus fréquent et le moins visible — les chiffres sont faux sans erreur apparente.
2. not_null — pas de valeur nulle sur une colonne critique
Vérifie qu'une colonne ne contient aucune valeur NULL. À poser sur chaque clé primaire, chaque clé de jointure, et chaque colonne obligatoire pour le métier.
columns:
- name: order_line_id
tests:
- unique
- not_null # une PK ne doit jamais être NULL
- name: customer_id
tests:
- not_null # pas de vente sans client
- relationships:
to: ref('stg_customers')
field: customer_id
- name: order_date
tests:
- not_null # pas de vente sans date
- name: amount
tests:
- not_null # pas de montant NULL
Le piège : sur Snowflake, une LEFT JOIN qui ne matche pas met NULL. Sans not_null sur la clé de jointure, tu te retrouves avec une table de fait qui a 30% de customer_id NULL et personne ne le sait.
3. accepted_values — pas de valeur hors domaine
Vérifie qu'une colonne ne contient que des valeurs autorisées. À poser sur chaque colonne de type énumération (statut, type, catégorie).
columns:
- name: order_status
tests:
- accepted_values:
values: ['pending', 'confirmed', 'shipped', 'delivered', 'cancelled']
- name: currency_code
tests:
- accepted_values:
values: ['EUR', 'USD', 'GBP', 'CHF', 'CAD']
Quand ça sauve la vie : une source qui ajoute un nouveau statut sans prévenir ('returned') et casse tous les dashboards qui filtre sur order_status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled').
4. relationships — intégrité référentielle
Vérifie que chaque valeur d'une colonne existe bien dans une autre table. C'est l'équivalent d'une foreign key, mais sans la contrainte physique.
columns:
- name: customer_id
tests:
- not_null
- relationships:
to: ref('stg_customers')
field: customer_id
-- Ce que dbt génère :
SELECT DISTINCT stg_sales.customer_id
FROM SILVER_DEV.SALES.stg_sales
LEFT JOIN SILVER_DEV.SALES.stg_customers
ON stg_sales.customer_id = stg_customers.customer_id
WHERE stg_customers.customer_id IS NULL
-- Les ventes qui référencent un client inexistant
Quand ça sauve la vie : un client supprimé dans la source (soft delete) mais ses commandes toujours présentes. Le relationships test détecte les ventes orphelines.
Les 6 tests que j'ajoute au-delà des natifs
5. expression_is_true (dbt-utils) — assertion métier
Vérifie qu'une expression SQL est toujours vraie. C'est le test le plus puissant pour les règles métier.
# Installation : packages.yml
packages:
- package: dbt-labs/dbt_utils
version: [">=1.3.0"]
# models/gold/reporting.yml
models:
- name: fct_sales_margin
tests:
- dbt_utils.expression_is_true:
expression: "revenue >= 0"
# Le chiffre d'affaires ne peut pas être négatif
- dbt_utils.expression_is_true:
expression: "margin <= revenue"
# La marge ne peut pas dépasser le CA
- dbt_utils.expression_is_true:
expression: "discount <= revenue * 0.5"
# Une remise ne peut pas dépasser 50% du CA
Quand ça sauve la vie : un calcul de marge négative, un discount supérieur au CA, un taux de TVA à 120%. Ce sont des incohérences business que les tests techniques ne détectent pas.
6. equal_rowcount (dbt-utils) — pas de perte de lignes
Vérifie que deux tables ont le même nombre de lignes. À poser entre un modèle source et sa transformation, pour détecter les jointures qui perdent des lignes.
models:
- name: stg_orders_enriched
tests:
- dbt_utils.equal_rowcount:
compare_model: ref('stg_orders')
# stg_orders_enriched doit avoir le même nombre de lignes que stg_orders
Quand ça sauve la vie : un INNER JOIN qui devrait être un LEFT JOIN et qui perd 15% des lignes silencieusement. Le dashboard affiche des totaux incomplets et personne ne comprend pourquoi.
7. fewer_rows_than (dbt-utils) — sens de la cardinalité
Vérifie qu'un modèle a moins de lignes que son parent. Idéal pour les aggregations.
models:
- name: fct_daily_sales
description: "Aggregation des ventes par jour"
tests:
- dbt_utils.fewer_rows_than:
compare_model: ref('stg_order_lines')
# L'agrégation par jour doit avoir moins de lignes que les lignes de commande
Quand ça sauve la vie : si ton agrégation a plus de lignes que la table source, c'est qu'il y a un CROSS JOIN ou une explosion de cardinalité.
8. Test de fraîcheur (source freshness) — données à jour
Vérifie que les données source sont récentes. C'est le test le plus important côté source — avant même les transformations.
# models/sources.yml
sources:
- name: postgres_app
database: RAW_DEV
schema: POSTGRES
freshness:
warn_after:
count: 6
period: hour
error_after:
count: 12
period: hour
loaded_at_field: updated_at
tables:
- name: customers
- name: orders
freshness:
warn_after:
count: 2
period: hour
error_after:
count: 4
period: hour
# Les commandes doivent être fraîches : warn si > 2h, error si > 4h
# En production, dans le DAG Airflow :
dbt source freshness --select source:postgres_app.*
# Si la dernière commande date d'il y a 5h -> ERROR
# Si la dernière commande date d'il y a 3h -> WARN
# Si la dernière commande date d'il y a 30 min -> PASS
Quand ça sauve la vie : le CDC Airbyte qui s'arrête de tourner à 14h, tout le monde pense que les données sont à jour, et le reporting du soir est basé sur des données de 6h avant. Le freshness test détecte ça avant que le DAG de transformation ne tourne.
9. Singular test custom SQL — les assertions que personne n'a pensées
Quand les tests génériques ne suffisent pas, on écrit un test SQL custom. C'est là que dbt devient puissant : n'importe quelle assertion SQL devient un test.
-- tests/assert_no_future_orders.sql
-- Une commande ne peut pas avoir une date dans le futur
SELECT *
FROM {{ ref('stg_orders') }}
WHERE order_date > CURRENT_DATE()
-- tests/assert_margin_anomaly.sql
-- Détecter les marges anormales (> 3 écarts-types de la moyenne)
WITH stats AS (
SELECT
AVG(margin) as avg_margin,
STDDEV(margin) as std_margin
FROM {{ ref('fct_sales_margin') }}
)
SELECT *
FROM {{ ref('fct_sales_margin') }}, stats
WHERE ABS(margin - avg_margin) > 3 * std_margin
-- tests/assert_no_duplicate_customers.sql
-- Pas de doublon sur l'email client (un client = un email)
SELECT email, COUNT(*) as n
FROM {{ ref('stg_customers') }}
GROUP BY email
HAVING COUNT(*) > 1
Un singular test = un fichier .sql dans tests/. dbt l'exécute et échoue s'il retourne des lignes. C'est simple, puissant, et ça comble tout ce que les tests génériques ne couvrent pas.
10. not_empty_string (dbt-utils) — pas de chaîne vide
Le piège du not_null : une chaîne vide '' n'est pas NULL. not_null passe, mais la donnée est absente.
columns:
- name: customer_name
tests:
- not_null
- dbt_utils.not_empty_string
- name: email
tests:
- not_null
- dbt_utils.not_empty_string
-- not_null ne détecte pas ça :
INSERT INTO customers (id, name) VALUES (1, ''); -- not_null passe, mais le nom est vide
-- not_empty_string détecte :
SELECT * FROM stg_customers WHERE name = ''; -- test échoue
Quand ça sauve la vie : une source qui envoie des chaînes vides au lieu de NULL. Le not_null passe, le dashboard affiche des noms vides, le métier perd confiance.
Comment je structure les tests sur chaque projet
Le schéma de test que je pose
models/
├── silver/
│ ├── stg_orders.yml # tests sur les modèles staging
│ └── stg_customers.yml
├── gold/
│ ├── fct_sales.yml # tests sur les faits
│ └── dim_customers.yml # tests sur les dimensions
sources/
└── sources.yml # freshness sur les sources
tests/
├── assert_no_future_orders.sql # singular tests custom
├── assert_margin_anomaly.sql
└── assert_no_duplicate_customers.sql
La règle : chaque modèle a son fichier .yml avec au minimum unique + not_null sur la PK. Pas de modèle sans test.
Les tests par couche
| Couche | Tests obligatoires | Tests recommandés |
|---|---|---|
| Source (freshness) | freshness (warn/error) |
— |
| Staging (silver) | unique, not_null sur PK |
accepted_values, relationships, not_empty_string |
| Marts (gold) | unique, not_null sur PK |
expression_is_true (règles métier), equal_rowcount |
| Facts | unique, not_null sur PK |
fewer_rows_than vs source, singular tests custom |
Le pipeline de test en CI/CD
Le test ne sert à rien s'il n'est pas automatisé. Le workflow que je déploie (déjà décrit dans mon article sur Airflow + dbt + Snowflake) :
# 1. Avant le merge (pull request) :
dbt test --select state:modified+ # tests sur les modèles modifiés
# 2. Après le merge (déploiement) :
dbt run # transformations
dbt test # tous les tests
dbt source freshness # fraîcheur des sources
# 3. En production (Airflow DAG quotidien) :
dbt run --select state:modified+ # only modified models
dbt test --select state:modified+ # only modified tests
dbt source freshness # source freshness check
Warehouse dédié aux tests
Sur Snowflake, je crée un warehouse dédié aux tests pour isoler le coût et la performance :
# dbt project.yml (ou profiles.yml)
tests:
+warehouse: "TESTING_WH"
+threads: 4
Un X-SMALL suffit pour les tests. Ça coûte presque rien et ça évite que les tests ne consomment les crédits du warehouse de transformation.
Le résultat
| Métrique | Avant (4 tests) | Après (10 tests systématiques) |
|---|---|---|
| Tests par projet | 4 | 60-120 (selon nombre de modèles) |
| Bug détecté par le métier | Fréquent | Rare (détecté en CI/CD) |
| Temps debug | Semaines (reverse-engineering) | Minutes (le test dit où) |
| Confiance utilisateurs | "Je vérifie toujours" | "Je fais confiance" |
| Coût tests Snowflake | — | < 5€/mois (X-SMALL Testing_WH) |
| Couverture critique | 5% des modèles | 100% des PK |
Les erreurs que je vois chez tous les clients
| Erreur | Fréquence | Conséquence |
|---|---|---|
Pas de unique sur la PK |
70% des projets | Doublons silencieux, chiffres faux |
not_null sans not_empty_string |
90% des projets | Chaînes vides non détectées |
| Pas de freshness sur les sources | 80% des projets | Données stale sans alerte |
| Tests seulement sur le gold | 60% des projets | Bugs détectés trop tard (déjà propagés) |
| Pas de tests en CI/CD | 75% des projets | Tests tournent jamais (ou manuellement) |
accepted_values oublié sur les enums |
85% des projets | Nouvelle valeur source casse les dashboards |
| Pas de singular test pour les règles métier | 95% des projets | Incohérences business non détectées |
Ce que les tests ne remplacent pas
Les tests dbt détectent les problèmes de données. Ils ne détectent pas :
- Les bugs de logique (un calcul faux qui produit des chiffres faux mais cohérents) → tests unitaires dbt (unit tests, v1.10+)
- Les problèmes de performance (un modèle qui prend 45 min) → monitoring Snowflake (query history, ACCOUNT_USAGE)
- Les drifts de schéma (une source qui ajoute/supprime une colonne) → dbt contracts ou source freshness
Les tests sont une couche nécessaire mais pas suffisante. La data quality complète combine tests dbt + monitoring Snowflake + alerting (Datadog, Slack). Mais sans tests dbt, le reste ne sert à rien — tu alertes sur des données que tu n'as jamais validées.
Tester avec dbt n'est pas optionnel. C'est la différence entre une plateforme data que le métier utilise avec confiance et une plateforme où chacun vérifie les chiffres dans Excel avant de les présenter. Les 4 tests natifs prennent 10 minutes à poser. Les 6 tests avancés prennent une demi-journée. Et ils te sauvent des semaines de debug et ta réputation.