RSS →
ARTICLES · DBT

DBT tests : les 10 tests que je pose systématiquement sur chaque projet

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.

DBT tests

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.

M

Mikael Paulhiout

Lead Tech / Data Architect. Écrit sur Snowflake, DBT, Airflow et l'IA appliquée.

RSS LinkedIn