Adrienne Vermorel

Unit Testing des modèles dbt : Exemples concrets et patterns

Ceci est la Partie 2 d’une série en 3 parties sur les tests unitaires dbt. En s’appuyant sur la syntaxe de la Partie 1, cet article couvre des patterns pratiques pour les modèles incrémentaux, les snapshots, le SQL complexe et l’analytics marketing.


La Partie 1 couvrait les fondamentaux : la syntaxe YAML, les particularités de BigQuery et la configuration CI/CD. Maintenant, appliquons ces connaissances aux modèles qui vous empêchent vraiment de dormir : la logique incrémentale, les fonctions de fenêtrage, les modèles d’attribution, et tous les cas limites qui passent entre les mailles de la code review.

Cet article est une bibliothèque de patterns. Chaque section aborde un scénario spécifique avec des exemples prêts à copier-coller que vous pouvez adapter à vos propres modèles. Mais plus important encore, il explique pourquoi chaque pattern compte et comment réfléchir à la conception de ces tests. Parce que copier-coller du YAML sans comprendre ce que vous testez, c’est ainsi qu’on se retrouve avec une pipeline CI verte et des dashboards cassés.

Tester les modèles incrémentaux

Les modèles incrémentaux sont la source la plus courante de bugs subtils dans les projets dbt. Pourquoi ? Parce qu’ils ont deux chemins de code complètement différents—un qui s’exécute lors d’un full refresh, un autre qui s’exécute lors des chargements incrémentaux normaux—et les équipes ne testent souvent qu’un seul d’entre eux (généralement le chemin full refresh, puisque c’est ce que dbt run fait en développement).

Le mode d’échec typique ressemble à ceci : Vous développez votre modèle incrémental, il fonctionne parfaitement en dev. Vous déployez en production, exécutez un full refresh, tout semble parfait. Trois mois plus tard, quelqu’un remarque des doublons qui apparaissent dans les rapports en aval. La logique incrémentale avait un bug qui ne s’est manifesté qu’après des mois d’exécutions quotidiennes, quand un cas limite subtil dans votre clause WHERE a finalement touché les données de production.

Les tests unitaires vous permettent de détecter ces bugs avant le déploiement en simulant les deux chemins d’exécution avec des données de test contrôlées.

L’approche de test dual-mode

Chaque modèle incrémental a besoin d’au moins deux tests unitaires : un pour le mode full refresh, un pour le mode incrémental. Ce n’est pas optionnel—si vous ne testez qu’un seul mode, vous laissez la moitié de votre logique non testée.

-- models/intermediate/int__events_processed.sql
{{ config(materialized='incremental', unique_key='event_id') }}
select
event_id,
user_id,
event_type,
event_timestamp,
processed_at
from {{ ref('base__ga4__events') }}
{% if is_incremental() %}
where event_timestamp > (select max(event_timestamp) from {{ this }})
{% endif %}

Ce modèle a deux comportements :

  1. Full refresh : Traiter tous les événements de la table source
  2. Incrémental : Ne traiter que les événements plus récents que l’événement le plus récent déjà dans la table cible

Le bloc {% if is_incremental() %} est là où les bugs se cachent. Les erreurs courantes incluent les erreurs off-by-one (utiliser >= vs >), les décalages de fuseau horaire, et les références de colonnes incorrectes. Écrivons des tests qui détecteraient ces problèmes.

Tester les deux modes :

models/intermediate/_unit_tests.yml
unit_tests:
# Test 1: Comportement full refresh
- name: test_int_events_processed_full_refresh
model: int__events_processed
description: "En full refresh, toutes les lignes source doivent être traitées"
overrides:
macros:
is_incremental: false
given:
- input: ref('base__ga4__events')
rows:
- {event_id: 1, user_id: 100, event_type: "click", event_timestamp: "2024-06-01 10:00:00"}
- {event_id: 2, user_id: 100, event_type: "purchase", event_timestamp: "2024-06-02 11:00:00"}
- {event_id: 3, user_id: 101, event_type: "click", event_timestamp: "2024-06-03 12:00:00"}
expect:
rows:
- {event_id: 1, user_id: 100, event_type: "click"}
- {event_id: 2, user_id: 100, event_type: "purchase"}
- {event_id: 3, user_id: 101, event_type: "click"}
# Test 2: Comportement incrémental
- name: test_int_events_processed_incremental
model: int__events_processed
description: "En exécution incrémentale, seuls les nouveaux événements doivent être insérés"
overrides:
macros:
is_incremental: true
given:
- input: ref('base__ga4__events')
rows:
- {event_id: 1, user_id: 100, event_type: "click", event_timestamp: "2024-06-01 10:00:00"}
- {event_id: 2, user_id: 100, event_type: "purchase", event_timestamp: "2024-06-02 11:00:00"}
- {event_id: 3, user_id: 101, event_type: "click", event_timestamp: "2024-06-03 12:00:00"}
- input: this # État actuel de la table cible
rows:
- {event_id: 1, user_id: 100, event_type: "click", event_timestamp: "2024-06-01 10:00:00"}
expect:
# Seulement les lignes PLUS RÉCENTES que max(event_timestamp) dans `this`
rows:
- {event_id: 2, user_id: 100, event_type: "purchase"}
- {event_id: 3, user_id: 101, event_type: "click"}

Décortiquons ce qui se passe dans ces tests :

Test 1 (Full Refresh) :

  • Nous définissons is_incremental: false dans les overrides, simulant un dbt run --full-refresh
  • Nous fournissons trois événements dans la table source
  • Nous attendons que les trois événements apparaissent en sortie—aucun filtrage ne se produit

Test 2 (Incrémental) :

  • Nous définissons is_incremental: true, simulant une exécution incrémentale normale
  • Nous fournissons les mêmes trois événements source, plus nous mockons l’état actuel de la table cible en utilisant input: this
  • La table this contient un événement avec le timestamp 2024-06-01 10:00:00
  • La clause WHERE de notre modèle filtre les événements après ce timestamp
  • Seuls les événements 2 et 3 (avec des timestamps du 2 et 3 juin) devraient être traités

Point crucial : Le bloc expect représente ce qui est inséré ou fusionné, pas l’état final de la table. Dans le test incrémental ci-dessus, event_id 1 existe déjà dans this, donc seuls les événements 2 et 3 apparaissent dans la sortie attendue. Cela trompe beaucoup de développeurs qui s’attendent à voir le résultat fusionné—mais les tests unitaires valident la logique de transformation, pas l’opération de merge elle-même.

Tester la logique de merge

La config merge_update_columns ajoute une autre couche de complexité. Quand une ligne avec une unique_key existante arrive, BigQuery ne mettra à jour que les colonnes spécifiées—laissant les autres inchangées. C’est utile pour les patterns de dimension à évolution lente, mais c’est aussi une source courante de confusion.

Considérez ce scénario : Vous avez une table utilisateurs où vous voulez mettre à jour l’email quand il change, mais préserver le timestamp created_at original. La logique de merge gère cela, mais comment vérifier que ça fonctionne correctement ?

-- models/intermediate/int__users_current.sql
{{ config(
materialized='incremental',
unique_key='user_id',
merge_update_columns=['email', 'updated_at']
) }}
select
user_id,
email,
created_at,
updated_at
from {{ ref('base__crm__users') }}
{% if is_incremental() %}
where updated_at > (select max(updated_at) from {{ this }})
{% endif %}
unit_tests:
- name: test_int_users_current_merge_update
model: int__users_current
description: "Les utilisateurs existants doivent avoir leur email mis à jour, pas created_at"
overrides:
macros:
is_incremental: true
given:
- input: ref('base__crm__users')
rows:
- {user_id: 1, email: "new@example.com", created_at: "2024-06-15", updated_at: "2024-06-15"}
- input: this
rows:
- {user_id: 1, email: "old@example.com", created_at: "2024-01-01", updated_at: "2024-01-01"}
expect:
rows:
- {user_id: 1, email: "new@example.com", created_at: "2024-06-15", updated_at: "2024-06-15"}

Mise en garde importante : Ce test valide que votre logique de transformation produit la bonne ligne en sortie. Il ne teste pas réellement le comportement du merge—c’est la responsabilité de BigQuery. Ce que vous testez ici est : “Étant donné ces données source et cet état existant, mon modèle produit-il la bonne ligne à fusionner ?”

La sortie attendue montre created_at: "2024-06-15" parce que c’est ce que les données source contiennent. En pratique, le merge réel préserverait le created_at: "2024-01-01" original de la table this. Si vous avez besoin de tester des scénarios de merge plus complexes, envisagez d’utiliser dbt-audit-helper pour comparer les résultats réels après une exécution de test.

Tester les données arrivant en retard

Les pipelines de données du monde réel reçoivent rarement les événements dans un ordre chronologique parfait. Les applications mobiles regroupent les événements pendant les périodes hors ligne. Les API tierces ont des délais de traitement. Les systèmes CDC peuvent rejouer des changements historiques. Votre logique incrémentale doit gérer ces “arrivées tardives” gracieusement.

Un pattern courant utilise une fenêtre de lookback : au lieu de ne traiter que les événements plus récents que max(event_timestamp), vous incluez aussi les événements qui sont arrivés récemment (basé sur une colonne _loaded_at) même si leur timestamp d’événement est plus ancien. Cela garantit que vous ne manquez pas de données retardées en transit.

unit_tests:
- name: test_int_events_processed_late_arriving
model: int__events_processed
description: "Les événements arrivant en retard dans la fenêtre de lookback doivent être capturés"
overrides:
macros:
is_incremental: true
vars:
lookback_hours: 24
given:
- input: ref('base__ga4__events')
rows:
# Événement arrivé en retard mais timestamp ancien
- {event_id: 4, event_timestamp: "2024-06-01 08:00:00", _loaded_at: "2024-06-02 10:00:00"}
# Nouvel événement normal
- {event_id: 5, event_timestamp: "2024-06-02 09:00:00", _loaded_at: "2024-06-02 09:05:00"}
- input: this
rows:
- {event_id: 1, event_timestamp: "2024-06-01 10:00:00"}
expect:
rows:
- {event_id: 4, event_timestamp: "2024-06-01 08:00:00"}
- {event_id: 5, event_timestamp: "2024-06-02 09:00:00"}

Dans ce test :

  • L’événement 4 a un timestamp ancien (1er juin, 08:00) mais un temps de chargement récent (2 juin, 10:00)—il est arrivé en retard
  • L’événement 5 est un nouvel événement normal avec à la fois un timestamp et un temps de chargement récents
  • La table this montre que nous avons déjà traité jusqu’au 1er juin, 10:00
  • Un simple WHERE event_timestamp > max(event_timestamp) manquerait l’événement 4
  • La fenêtre de lookback (contrôlée par la variable lookback_hours) garantit qu’on le capture

Ce test vérifie que votre gestion des arrivées tardives fonctionne correctement. Sans lui, vous pourriez ne pas remarquer les événements manquants jusqu’à ce qu’un analyste en aval demande pourquoi ses chiffres ne correspondent pas au système source.

Prérequis : Avant d’exécuter des tests unitaires incrémentaux, construisez vos modèles incrémentaux avec dbt run --select "config.materialized:incremental" --empty pour vous assurer que le schéma existe. Les tests unitaires ont besoin d’introspecter la structure de la table, et ils échoueront avec “Not able to get columns for unit test” si la table cible n’existe pas.

Tester la logique Snapshot/SCD Type 2

Les snapshots (Slowly Changing Dimension Type 2) suivent les changements historiques de vos données. Quand l’email d’un client change, au lieu d’écraser l’ancienne valeur, dbt crée une nouvelle ligne avec des timestamps de validité—préservant l’historique complet des changements.

Voici le défi : les tests unitaires natifs dbt ne supportent pas directement les ressources snapshot. Vous ne pouvez pas écrire un test unitaire avec model: my_snapshot parce que les snapshots ne sont pas des modèles—c’est un type de ressource séparé avec leur propre chemin d’exécution.

Cette limitation fait sens quand on y réfléchit. Les snapshots détectent les changements au fil du temps, comparant l’état source actuel à l’état du snapshot précédent. Les tests unitaires s’exécutent en isolation avec des données mockées—il n’y a pas d‘“état précédent” à comparer.

Alors comment tester la logique liée aux snapshots ? Trois stratégies :

  1. Tester les modèles qui alimentent les snapshots — s’assurer que votre couche staging produit des données correctes pour que le snapshot les consomme
  2. Tester les modèles qui consomment la sortie du snapshot — valider vos calculs de plages de dates SCD2 et les flags is_current
  3. Tester la logique de détection des changements — si vous utilisez la détection de changements basée sur le hash, vérifier que le hashing fonctionne correctement

Tester les modèles base pré-snapshot

La transformation qui prépare les données pour un snapshot est tout aussi importante que le snapshot lui-même. Si votre modèle staging a des bugs, le snapshot préservera fidèlement ces bugs pour toujours.

unit_tests:
- name: test_base_crm_users_snapshot_ready
model: base__crm__users
description: "Le modèle base doit correctement préparer les données pour le snapshot"
given:
- input: source('crm', 'users')
rows:
- {id: 1, name: "Alice", email: "alice@example.com", _updated_at: "2024-06-01"}
- {id: 1, name: "Alice Smith", email: "alice@example.com", _updated_at: "2024-06-15"}
expect:
rows:
- {user_id: 1, user_name: "Alice", email: "alice@example.com", updated_at: "2024-06-01"}
- {user_id: 1, user_name: "Alice Smith", email: "alice@example.com", updated_at: "2024-06-15"}

Ce test vérifie que :

  • Les noms de colonnes bruts sont correctement transformés (iduser_id, nameuser_name)
  • La colonne updated_at (que le snapshot utilisera pour la détection des changements) est correctement exposée
  • Plusieurs versions du même utilisateur sont préservées (critique pour que SCD2 fonctionne correctement)

Tester la logique des plages de dates SCD2

Après l’exécution du snapshot, les modèles en aval consomment typiquement la sortie du snapshot et calculent des champs dérivés comme les dates valid_to et les flags is_current. C’est là que les fonctions de fenêtrage entrent en jeu—et où les bugs adorent se cacher.

Si vous avez un modèle qui consomme des données de snapshot et calcule les périodes de validité :

-- models/intermediate/int__users_history.sql
select
user_id,
user_name,
valid_from,
coalesce(
lead(valid_from) over (partition by user_id order by valid_from),
'2199-12-31'
) as valid_to,
case
when lead(valid_from) over (partition by user_id order by valid_from) is null
then true
else false
end as is_current
from {{ ref('snp_users') }}
unit_tests:
- name: test_int_users_history_date_ranges
model: int__users_history
description: "Valid_to doit être le valid_from de la version suivante, ou 2199-12-31 pour le courant"
given:
- input: ref('snp_users')
rows:
- {user_id: 1, user_name: "Alice", valid_from: "2024-01-01"}
- {user_id: 1, user_name: "Alice Smith", valid_from: "2024-06-15"}
- {user_id: 2, user_name: "Bob", valid_from: "2024-03-01"}
expect:
rows:
- {user_id: 1, user_name: "Alice", valid_from: "2024-01-01", valid_to: "2024-06-15", is_current: false}
- {user_id: 1, user_name: "Alice Smith", valid_from: "2024-06-15", valid_to: "2199-12-31", is_current: true}
- {user_id: 2, user_name: "Bob", valid_from: "2024-03-01", valid_to: "2199-12-31", is_current: true}

Le test vérifie plusieurs choses à la fois :

  1. Calcul des plages de dates : Le premier enregistrement d’Alice doit avoir valid_to = "2024-06-15" (le valid_from de la version suivante), tandis que son deuxième enregistrement utilise la date sentinelle du futur lointain
  2. Logique du flag current : Seule la version la plus récente de chaque utilisateur doit avoir is_current: true
  3. Gestion d’une seule version : Bob n’a qu’un seul enregistrement, donc il est automatiquement courant avec le valid_to sentinelle
  4. Isolation des partitions : Les enregistrements d’Alice n’affectent pas les calculs de Bob (chaque user_id est une partition séparée)

C’est le genre de logique qu’il est presque impossible de vérifier par la seule code review. Le partitionnement, l’ordonnancement et les spécifications de frame des fonctions de fenêtrage interagissent tous de manières qui ne révèlent des bugs qu’avec des données réelles.

Tester la détection des changements

Certaines équipes implémentent leur propre logique de détection des changements en utilisant des hash au niveau des lignes, surtout quand elles travaillent avec des sources qui n’ont pas de timestamps updated_at fiables. L’idée est simple : concaténer toutes les colonnes pertinentes, hasher le résultat, et comparer les hash entre les exécutions pour détecter les changements.

La partie délicate ? Les fonctions de hash sont sensibles à l’ordre des colonnes, à la gestion des nulls, et au casting des types de données. Un changement qui semble cosmétique (réordonner les colonnes dans votre hash) peut complètement casser la détection des changements.

unit_tests:
- name: test_change_detection_hash
model: base__crm__users_with_hash
description: "Le hash de ligne doit changer quand n'importe quelle colonne suivie change"
given:
- input: source('crm', 'users')
rows:
- {id: 1, name: "Alice", email: "alice@example.com"}
- {id: 2, name: "Bob", email: "bob@example.com"}
expect:
rows:
- {user_id: 1, row_hash: "abc123"} # Calculer le hash attendu réel
- {user_id: 2, row_hash: "def456"}

Pour écrire ce test, vous devrez calculer les valeurs de hash attendues manuellement (ou exécuter votre modèle une fois et capturer les sorties). Le test devient alors une protection contre les régressions—si quelqu’un modifie accidentellement la logique de hash, le test le détectera.

Astuce pro : Envisagez d’ajouter un second cas de test où vous changez une valeur de colonne et vérifiez que le hash change de manière appropriée :

- name: test_change_detection_hash_sensitivity
model: base__crm__users_with_hash
description: "Le hash doit différer quand l'email change"
given:
- input: source('crm', 'users')
rows:
- {id: 1, name: "Alice", email: "alice@example.com"}
- {id: 1, name: "Alice", email: "alice.new@example.com"}
expect:
rows:
- {user_id: 1, row_hash: "abc123"} # Hash original
- {user_id: 1, row_hash: "xyz789"} # Hash différent (email changé)

Tester les patterns SQL complexes

Certaines constructions SQL sont intrinsèquement difficiles à raisonner par la seule code review. Les fonctions de fenêtrage, les chaînes CASE WHEN complexes, et les patterns regex bénéficient tous d’exemples concrets qui démontrent le comportement correct.

Fonctions de fenêtrage

Les fonctions de fenêtrage sont des candidats de choix pour les tests unitaires parce que leur comportement dépend de trois facteurs en interaction : le partitionnement (quelles lignes appartiennent ensemble), l’ordonnancement (séquence au sein de chaque partition), et le cadrage (quelles lignes sont incluses dans le calcul). Se tromper sur l’un de ces points et vos résultats seront subtilement incorrects d’une manière difficile à repérer.

Considérez ce modèle qui calcule des métriques au niveau de la session :

-- models/intermediate/int__users_sessions.sql
select
user_id,
event_timestamp,
row_number() over (partition by user_id order by event_timestamp) as session_event_number,
first_value(page_path) over (partition by user_id order by event_timestamp) as landing_page,
sum(page_views) over (partition by user_id order by event_timestamp rows unbounded preceding) as cumulative_page_views
from {{ ref('base__ga4__events') }}
unit_tests:
- name: test_int_users_sessions_window_functions
model: int__users_sessions
description: "Les fonctions de fenêtrage doivent correctement partitionner et ordonner par utilisateur"
given:
- input: ref('base__ga4__events')
rows:
# Événements de User 1 (dans le désordre dans la source)
- {user_id: 1, event_timestamp: "2024-06-01 10:05:00", page_path: "/products", page_views: 2}
- {user_id: 1, event_timestamp: "2024-06-01 10:00:00", page_path: "/home", page_views: 1}
- {user_id: 1, event_timestamp: "2024-06-01 10:10:00", page_path: "/cart", page_views: 1}
# Événements de User 2
- {user_id: 2, event_timestamp: "2024-06-01 11:00:00", page_path: "/about", page_views: 3}
expect:
rows:
# User 1 - ordonné par timestamp
- {user_id: 1, event_timestamp: "2024-06-01 10:00:00", session_event_number: 1, landing_page: "/home", cumulative_page_views: 1}
- {user_id: 1, event_timestamp: "2024-06-01 10:05:00", session_event_number: 2, landing_page: "/home", cumulative_page_views: 3}
- {user_id: 1, event_timestamp: "2024-06-01 10:10:00", session_event_number: 3, landing_page: "/home", cumulative_page_views: 4}
# User 2
- {user_id: 2, event_timestamp: "2024-06-01 11:00:00", session_event_number: 1, landing_page: "/about", cumulative_page_views: 3}

Remarquez comment les données de test sont délibérément conçues :

  1. Lignes source dans le désordre : Les événements de User 1 sont listés dans le désordre chronologique dans le bloc given (10:05, 10:00, 10:10). Cela vérifie que le ORDER BY event_timestamp fonctionne réellement—si vous ordonnez accidentellement par event_id à la place, le test échouerait.

  2. Plusieurs utilisateurs : Inclure User 2 confirme que le partitionnement fonctionne correctement. Les événements de User 1 ne devraient pas affecter le session_event_number ou la landing_page de User 2.

  3. Calcul cumulatif : Les valeurs page_views (1, 2, 1, 3) sont choisies pour rendre le total cumulé facile à vérifier. Si la clause de frame est incorrecte (disons, sans ROWS UNBOUNDED PRECEDING), les nombres ne s’additionneront pas.

Ce test unique vérifie ROW_NUMBER, FIRST_VALUE, et SUM avec un window frame—tout en un seul passage. Si l’une des spécifications de fenêtre est incorrecte, au moins une valeur attendue sera incorrecte.

Logique CASE WHEN complexe

La logique métier vit souvent dans les instructions CASE WHEN. Segmentation client, paliers de prix, calculs de statut—ces conditionnelles à plusieurs branches sont trompeusement simples à écrire et étonnamment faciles à mal faire.

Les bugs les plus courants :

  • Conditions qui se chevauchent : Deux branches qui peuvent toutes deux correspondre (l’ordre d’évaluation compte)
  • Valeurs limites manquantes : Erreurs off-by-one aux seuils des paliers
  • Gestion des nulls : Oublier que NULL >= 1000 retourne NULL, pas FALSE
  • ELSE implicite : S’appuyer sur le ELSE NULL par défaut quand on voulait gérer tous les cas explicitement

La solution est le test systématique des limites—créer des lignes de test qui touchent chaque seuil :

-- models/marts/core/mrt__core__customer_segments.sql
select
customer_id,
total_spend,
case
when total_spend >= 10000 then 'platinum'
when total_spend >= 5000 then 'gold'
when total_spend >= 1000 then 'silver'
when total_spend > 0 then 'bronze'
else 'inactive'
end as segment
from {{ ref('int__customers_summary') }}
unit_tests:
- name: test_mrt_core_customer_segments_all_tiers
model: mrt__core__customer_segments
description: "Chaque seuil de dépenses doit correspondre au bon segment"
given:
- input: ref('int__customers_summary')
rows:
- {customer_id: 1, total_spend: 15000} # Platinum
- {customer_id: 2, total_spend: 10000} # Platinum (limite)
- {customer_id: 3, total_spend: 9999} # Gold (juste en dessous)
- {customer_id: 4, total_spend: 5000} # Gold (limite)
- {customer_id: 5, total_spend: 1000} # Silver (limite)
- {customer_id: 6, total_spend: 1} # Bronze
- {customer_id: 7, total_spend: 0} # Inactive
- {customer_id: 8, total_spend: null} # Inactive (cas null)
expect:
rows:
- {customer_id: 1, segment: "platinum"}
- {customer_id: 2, segment: "platinum"}
- {customer_id: 3, segment: "gold"}
- {customer_id: 4, segment: "gold"}
- {customer_id: 5, segment: "silver"}
- {customer_id: 6, segment: "bronze"}
- {customer_id: 7, segment: "inactive"}
- {customer_id: 8, segment: "inactive"}

Les données de test sont intentionnellement exhaustives :

  • Valeurs limites : Les clients 2, 4 et 5 testent les correspondances exactes aux seuils (10000, 5000, 1000). Si quelqu’un utilise > au lieu de >=, ceux-ci échoueront.
  • Valeurs juste en dessous : Le client 3 teste 9999 pour s’assurer que gold (pas platinum) s’applique
  • Cas limites : Le client 6 teste la plus petite valeur positive, le client 7 teste zéro
  • Gestion des nulls : Le client 8 vérifie que les dépenses null correspondent à “inactive” plutôt que de causer une erreur ou un comportement inattendu

Cette approche—tester les limites, pas seulement les valeurs typiques—détecte les bugs les plus courants dans la logique conditionnelle.

Regex et manipulation de chaînes

La logique d’extraction de chaînes est notoirement fragile. Les patterns regex qui fonctionnent pour vos données typiques échouent souvent sur les cas limites. Les tests unitaires sont essentiels pour documenter le comportement attendu et détecter les régressions.

unit_tests:
- name: test_mrt_core_customers_email_domain_extraction
model: mrt__core__customers
description: "L'extraction du domaine email gère divers formats"
given:
- input: ref('base__crm__customers')
rows:
- {customer_id: 1, email: "alice@example.com"}
- {customer_id: 2, email: "bob.smith@subdomain.company.co.uk"}
- {customer_id: 3, email: "CAPS@DOMAIN.COM"}
- {customer_id: 4, email: "invalid-email"}
- {customer_id: 5, email: null}
expect:
rows:
- {customer_id: 1, email_domain: "example.com"}
- {customer_id: 2, email_domain: "subdomain.company.co.uk"}
- {customer_id: 3, email_domain: "domain.com"}
- {customer_id: 4, email_domain: null}
- {customer_id: 5, email_domain: null}

Chaque ligne de test a un objectif spécifique :

  • Cas simple : alice@example.com — comportement de base
  • Domaine complexe : subdomain.company.co.uk — plusieurs points, cas limite TLD
  • Gestion de la casse : CAPS@DOMAIN.COM — devrait normaliser en minuscules
  • Entrée invalide : invalid-email — pas de symbole @, devrait gracieusement retourner null plutôt qu’erreur
  • Entrée null : null — gestion explicite des nulls

Sans ces tests, quelqu’un pourrait “simplifier” votre regex et casser le cas du sous-domaine, ou changer la gestion des nulls sans réaliser que les modèles en aval en dépendent.

Patterns de test pour l’analytics marketing

Les modèles d’analytics marketing contiennent souvent la logique la plus complexe—et aux enjeux les plus élevés—d’un projet dbt. L’attribution détermine comment le budget marketing est alloué. La sessionisation affecte les calculs de taux de conversion. L’analyse de funnel guide les décisions produit. Quand ces modèles ont des bugs, l’entreprise prend des décisions basées sur des données erronées.

Les patterns de cette section viennent d’implémentations réelles. Ils sont plus complexes que les tests unitaires basiques, mais ils testent une logique qui compte vraiment.

Sessionisation GA4

Google Analytics 4 envoie des événements bruts à BigQuery, mais ces événements manquent d’agrégations au niveau de la session. Vous devez construire vous-même la logique de sessionisation—regrouper les événements par utilisateur, détecter les limites de session (typiquement 30 minutes d’inactivité), et calculer des métriques comme la durée de session et le nombre d’événements.

Cette logique implique :

  • L’arithmétique des timestamps (utiliser timestamp_micros() pour convertir les timestamps en microsecondes de GA4)
  • Les fonctions de fenêtrage pour calculer les écarts de temps entre les événements
  • La détection des limites de session basée sur des seuils d’inactivité configurables
  • L’agrégation pour créer des enregistrements au niveau session à partir de données au niveau événement

Testons-la :

-- models/intermediate/int__ga4_sessions.sql
with events as (
select
user_pseudo_id,
ga_session_id,
event_timestamp,
event_name,
-- Calculer le temps depuis l'événement précédent
timestamp_diff(
timestamp_micros(event_timestamp),
lag(timestamp_micros(event_timestamp)) over (
partition by user_pseudo_id
order by event_timestamp
),
minute
) as minutes_since_last_event
from {{ ref('base__ga4__events') }}
),
sessionized as (
select
*,
-- Nouvelle session si écart > 30 minutes ou premier événement
case
when minutes_since_last_event > 30 or minutes_since_last_event is null
then 1
else 0
end as is_new_session
from events
)
select
user_pseudo_id,
ga_session_id,
concat(user_pseudo_id, '_', ga_session_id) as session_key,
min(event_timestamp) as session_start,
max(event_timestamp) as session_end,
timestamp_diff(
timestamp_micros(max(event_timestamp)),
timestamp_micros(min(event_timestamp)),
second
) as session_duration_seconds,
count(*) as event_count
from sessionized
group by 1, 2, 3
unit_tests:
- name: test_int_ga4_sessions_boundaries
model: int__ga4_sessions
description: "Les sessions doivent se couper après 30 minutes d'inactivité"
given:
- input: ref('base__ga4__events')
rows:
# User 1, Session 1: événements dans les 30 min
- {user_pseudo_id: "user_1", ga_session_id: 1001, event_timestamp: 1717200000000000, event_name: "page_view"}
- {user_pseudo_id: "user_1", ga_session_id: 1001, event_timestamp: 1717201800000000, event_name: "scroll"} # +30 min
# User 1, Session 2: écart > 30 min
- {user_pseudo_id: "user_1", ga_session_id: 1002, event_timestamp: 1717207200000000, event_name: "page_view"} # +90 min depuis le premier
# User 2, Session 1
- {user_pseudo_id: "user_2", ga_session_id: 2001, event_timestamp: 1717200000000000, event_name: "page_view"}
expect:
rows:
- {session_key: "user_1_1001", session_duration_seconds: 1800, event_count: 2}
- {session_key: "user_1_1002", session_duration_seconds: 0, event_count: 1}
- {session_key: "user_2_2001", session_duration_seconds: 0, event_count: 1}

Le premier test vérifie le comportement de sessionisation de base :

  • Session 1001 : Deux événements à 30 minutes d’intervalle (exactement à la limite) appartiennent à la même session, résultant en 1800 secondes de durée
  • Session 1002 : Un troisième événement 90 minutes après le premier déclenche une nouvelle session (écart > 30 min depuis le dernier événement de la session 1001)
  • Session 2001 : L’événement unique de User 2 crée une session avec 0 seconde de durée

Les timestamps en microsecondes (1717200000000000) sont le format natif de GA4. Vous devrez calculer ces valeurs en fonction de vos scénarios de test.

- name: test_int_ga4_sessions_cross_midnight
model: int__ga4_sessions
description: "Les sessions traversant minuit ne doivent pas se couper artificiellement"
given:
- input: ref('base__ga4__events')
rows:
- {user_pseudo_id: "user_1", ga_session_id: 1001, event_timestamp: 1717199400000000, event_name: "page_view"} # 23:50
- {user_pseudo_id: "user_1", ga_session_id: 1001, event_timestamp: 1717200600000000, event_name: "purchase"} # 00:10 jour suivant
expect:
rows:
- {session_key: "user_1_1001", session_duration_seconds: 1200, event_count: 2} # 20 minutes

Le test de passage à minuit détecte un bug subtil : certaines implémentations coupent accidentellement les sessions aux limites de date (parce qu’elles partitionnent par date au lieu de par utilisateur). Un utilisateur naviguant à 23h50 qui achète à 00h10 devrait avoir une seule session de 20 minutes, pas deux sessions séparées.

Modélisation de l’attribution

Les modèles d’attribution répondent à la question : “Quels canaux marketing méritent le crédit pour cette conversion ?” La réponse détermine des millions de dollars en dépenses publicitaires.

Modèles d’attribution courants :

  • First-touch : Le crédit va au canal qui a acquis le client en premier
  • Last-touch : Le crédit va au canal immédiatement avant la conversion
  • Linéaire : Le crédit est réparti également entre tous les points de contact
  • Time-decay : Plus de crédit aux points de contact proches de la conversion

Chaque modèle utilise des fonctions de fenêtrage pour identifier le point de contact pertinent. Testons first-touch et last-touch :

-- models/marts/marketing/mrt__marketing__customer_attribution.sql
select
customer_id,
-- First touch
first_value(utm_source) over (
partition by customer_id
order by session_start
) as first_touch_source,
first_value(utm_medium) over (
partition by customer_id
order by session_start
) as first_touch_medium,
-- Last touch (avant conversion)
last_value(utm_source) over (
partition by customer_id
order by session_start
rows between unbounded preceding and unbounded following
) as last_touch_source,
-- Info de conversion
max(case when converted then session_start end) as conversion_timestamp
from {{ ref('int__customers_sessions') }}
where converted = true or session_start <= (
select min(session_start)
from {{ ref('int__customers_sessions') }} cs2
where cs2.customer_id = int__customers_sessions.customer_id and cs2.converted
)
group by customer_id
unit_tests:
- name: test_mrt_marketing_customer_attribution_first_last_touch
model: mrt__marketing__customer_attribution
description: "First touch capture la source initiale, last touch capture la source de conversion"
given:
- input: ref('int__customers_sessions')
rows:
# Parcours client: Facebook -> Google -> Direct (conversion)
- {customer_id: 100, session_start: "2024-06-01 10:00:00", utm_source: "facebook", utm_medium: "paid", converted: false}
- {customer_id: 100, session_start: "2024-06-05 14:00:00", utm_source: "google", utm_medium: "organic", converted: false}
- {customer_id: 100, session_start: "2024-06-10 09:00:00", utm_source: "direct", utm_medium: "none", converted: true}
# Conversion single-touch
- {customer_id: 101, session_start: "2024-06-02 11:00:00", utm_source: "email", utm_medium: "newsletter", converted: true}
expect:
rows:
- {customer_id: 100, first_touch_source: "facebook", first_touch_medium: "paid", last_touch_source: "direct"}
- {customer_id: 101, first_touch_source: "email", first_touch_medium: "newsletter", last_touch_source: "email"}

Ce test vérifie deux scénarios :

Parcours multi-touch du client 100 :

  1. Premier contact via les publicités Facebook payantes (1er juin)
  2. Retour via la recherche organique Google (5 juin)
  3. Conversion via une visite directe (10 juin)

La sortie attendue montre le crédit first-touch à Facebook (où le parcours a commencé) et le crédit last-touch à Direct (le dernier point de contact avant la conversion). Si vos fonctions de fenêtrage FIRST_VALUE ou LAST_VALUE ont un ordonnancement ou un cadrage incorrect, ce test le détectera.

Conversion single-touch du client 101 : Quand il n’y a qu’une seule session avant la conversion, first-touch et last-touch devraient être le même canal. Ce cas limite casse souvent quand les implémentations supposent au moins deux points de contact.

- name: test_mrt_marketing_customer_attribution_no_conversion
model: mrt__marketing__customer_attribution
description: "Les clients sans conversion ne doivent pas apparaître"
given:
- input: ref('int__customers_sessions')
rows:
- {customer_id: 200, session_start: "2024-06-01 10:00:00", utm_source: "facebook", utm_medium: "paid", converted: false}
- {customer_id: 200, session_start: "2024-06-05 14:00:00", utm_source: "google", utm_medium: "organic", converted: false}
expect:
rows: []

Le test sans conversion est tout aussi important. L’attribution n’a de sens que pour les clients qui ont effectivement converti. Le client 200 a eu des sessions mais n’a jamais acheté—il devrait être entièrement exclu du modèle d’attribution. Un bloc expect vide avec rows: [] vérifie ce comportement.

Funnels de conversion

L’analyse de funnel suit comment les utilisateurs progressent à travers une séquence définie d’étapes—page view → ajout au panier → checkout → achat. À chaque étape, certains utilisateurs abandonnent. Le taux de conversion entre les étapes est une métrique clé pour identifier les points de friction.

La logique implique :

  • Compter les utilisateurs distincts à chaque étape du funnel
  • Calculer les taux de conversion d’étape en étape (quel pourcentage des utilisateurs de l’étape N atteignent l’étape N+1)
  • Gérer les utilisateurs qui sautent des étapes (achat direct sans ajout au panier)
unit_tests:
- name: test_mrt_marketing_conversion_funnel_dropoff
model: mrt__marketing__conversion_funnel
description: "Le funnel doit calculer les taux de conversion entre les étapes"
given:
- input: ref('base__ga4__events')
rows:
# User 1: complète le funnel entier
- {user_id: 1, event_name: "page_view", event_date: "2024-06-01"}
- {user_id: 1, event_name: "add_to_cart", event_date: "2024-06-01"}
- {user_id: 1, event_name: "begin_checkout", event_date: "2024-06-01"}
- {user_id: 1, event_name: "purchase", event_date: "2024-06-01"}
# User 2: abandonne au checkout
- {user_id: 2, event_name: "page_view", event_date: "2024-06-01"}
- {user_id: 2, event_name: "add_to_cart", event_date: "2024-06-01"}
- {user_id: 2, event_name: "begin_checkout", event_date: "2024-06-01"}
# User 3: abandonne au panier
- {user_id: 3, event_name: "page_view", event_date: "2024-06-01"}
- {user_id: 3, event_name: "add_to_cart", event_date: "2024-06-01"}
# User 4: ne fait que voir
- {user_id: 4, event_name: "page_view", event_date: "2024-06-01"}
expect:
rows:
- {funnel_step: "page_view", user_count: 4, step_conversion_rate: 1.0}
- {funnel_step: "add_to_cart", user_count: 3, step_conversion_rate: 0.75}
- {funnel_step: "begin_checkout", user_count: 2, step_conversion_rate: 0.67}
- {funnel_step: "purchase", user_count: 1, step_conversion_rate: 0.5}

Traçons les données de test :

Userpage_viewadd_to_cartbegin_checkoutpurchase
1
2
3
4

Calcul des taux de conversion :

  • page_view : 4 utilisateurs, 100% (point de départ)
  • add_to_cart : 3 utilisateurs, 75% des utilisateurs page_view (3/4)
  • begin_checkout : 2 utilisateurs, 67% des utilisateurs add_to_cart (2/3)
  • purchase : 1 utilisateur, 50% des utilisateurs checkout (1/2)

Le test vérifie à la fois les comptages d’utilisateurs et les taux de conversion. Si votre modèle calcule le taux de conversion incorrectement (division par le total des utilisateurs au lieu des utilisateurs de l’étape précédente, par exemple), ce test échouera.

Stratégies de test des cas limites

Les données de production sont désordonnées. Des nulls apparaissent là où vous ne les attendez pas. Les tables sont parfois vides. Les dates atteignent des conditions limites. Les modèles qui survivent en production sont ceux conçus avec ces cas limites en tête.

Les tests unitaires sont votre outil pour documenter et appliquer le comportement des cas limites. Au lieu d’espérer que votre modèle gère les nulls gracieusement, vous le prouvez avec un test.

Gestion des nulls

La sémantique des nulls en SQL est notoirement confuse. NULL + 100 = NULL. NULL = NULL n’est pas TRUE. SUM() ignore les nulls, mais AVG() ne compte pas les lignes null dans le dénominateur. Ces comportements mènent à des bugs subtils qui ne se manifestent que quand des valeurs null apparaissent en production.

Testez que vos agrégations gèrent correctement les nulls :

unit_tests:
- name: test_int_customers_revenue_null_handling
model: int__customers_revenue
description: "Les valeurs de commande null doivent être traitées comme zéro dans la somme"
given:
- input: ref('base__shopify__orders')
rows:
- {customer_id: 1, order_value: 100}
- {customer_id: 1, order_value: null}
- {customer_id: 1, order_value: 50}
- {customer_id: 2, order_value: null} # Tous nulls
expect:
rows:
- {customer_id: 1, total_revenue: 150}
- {customer_id: 2, total_revenue: 0} # Ou null, selon votre logique

Ce test clarifie le comportement attendu :

  • Le client 1 a trois commandes : 100, null, 50. Est-ce que SUM() retourne 150 (ignorant null) ou null (propagation de null) ?
  • Le client 2 n’a que des commandes null. Est-ce que SUM() retourne 0, null, ou cause une erreur ?

Le commentaire “Ou null, selon votre logique” est intentionnel—votre modèle pourrait utiliser COALESCE(SUM(order_value), 0) pour assurer un zéro au lieu de null. Le test documente le comportement que vous avez choisi.

Tables vides

Que se passe-t-il quand une table en amont est vide ? Peut-être que c’est un nouveau déploiement sans données historiques. Peut-être qu’un système source a échoué et a livré zéro enregistrement. Votre modèle devrait gérer cela gracieusement—soit en retournant zéro ligne, soit en retournant une ligne avec des valeurs par défaut.

Le défi est que format: dict avec rows: [] ne fonctionne pas—dbt ne peut pas inférer les types de colonnes à partir d’une liste vide. La solution est format: sql avec une clause WHERE false :

unit_tests:
- name: test_mrt_finance_daily_revenue_empty_orders
model: mrt__finance__daily_revenue
description: "Le modèle doit retourner zéro revenu pour les jours sans commandes"
given:
- input: ref('base__shopify__orders')
format: sql
rows: |
select
cast(null as string) as order_id,
cast(null as date) as order_date,
cast(null as float64) as order_value
where false
- input: ref('int__dates')
rows:
- {date_key: "2024-06-01"}
expect:
rows:
- {date_key: "2024-06-01", daily_revenue: 0, order_count: 0}

Le format SQL utilise un casting explicite (cast(null as string)) pour définir le schéma des colonnes, puis WHERE false garantit que zéro ligne est retournée. Cela donne à dbt l’information de type dont il a besoin sans fournir de données.

Le test vérifie que quand les commandes sont vides mais que les dates existent, le modèle produit quand même une sortie avec zéro revenu—plutôt que de ne rien retourner ou de lancer une erreur. C’est particulièrement important pour les modèles de reporting qui doivent montrer toutes les dates même quand aucune activité n’a eu lieu.

Limites de dates

La logique de dates est un champ de mines. Les années fiscales qui ne s’alignent pas avec les années calendaires. Les années bissextiles. Les calculs de numéro de semaine qui varient selon le pays. Les fuseaux horaires qui changent pendant les transitions heure d’été. Ces cas limites causent de vrais bugs en production.

Un modèle de calendrier fiscal est un candidat parfait pour le test des limites :

unit_tests:
- name: test_int_fiscal_calendar_boundaries
model: int__fiscal_calendar
description: "L'année fiscale doit gérer correctement la fin d'année (AF commence le 1er avril)"
given:
- input: ref('int__dates')
rows:
- {calendar_date: "2024-03-31"} # Dernier jour de AF2024
- {calendar_date: "2024-04-01"} # Premier jour de AF2025
- {calendar_date: "2024-02-29"} # Année bissextile
- {calendar_date: "2024-12-31"} # Fin d'année calendaire
expect:
rows:
- {calendar_date: "2024-03-31", fiscal_year: 2024, fiscal_quarter: 4}
- {calendar_date: "2024-04-01", fiscal_year: 2025, fiscal_quarter: 1}
- {calendar_date: "2024-02-29", fiscal_year: 2024, fiscal_quarter: 4}
- {calendar_date: "2024-12-31", fiscal_year: 2025, fiscal_quarter: 3}

Ce test suppose une année fiscale commençant le 1er avril (courant dans de nombreuses organisations). Traçons :

  • 31 mars 2024 : Dernier jour de AF2024 T4 (l’année fiscale se termine le 31 mars)
  • 1er avril 2024 : Premier jour de AF2025 T1 (nouvelle année fiscale commence)
  • 29 février 2024 : Date d’année bissextile—vérifie que le modèle gère correctement le 29 février
  • 31 décembre 2024 : Fin d’année calendaire, mais milieu d’année fiscale (AF2025 T3)

Si quelqu’un code en dur une hypothèse d’année calendaire ou oublie de gérer les années bissextiles, au moins un de ces cas de test échouera.

Tester les macros et la logique réutilisable

Les macros sont les fonctions du monde dbt—une logique réutilisable que vous appelez depuis plusieurs modèles. Une macro de calcul de remise pourrait être utilisée dans des dizaines de modèles. Une macro de formatage de date pourrait être appelée des centaines de fois. Quand la logique de macro est incorrecte, le bug se propage partout.

Le défi : les tests unitaires dbt ciblent les modèles, pas directement les macros. Vous ne pouvez pas écrire model: my_macro dans votre YAML de test.

Le pattern du modèle éphémère

La solution est un modèle wrapper éphémère qui existe uniquement pour tester la macro. Les modèles éphémères ne créent pas de tables dans votre warehouse—ils sont compilés comme CTEs dans les modèles en aval. Cela les rend parfaits pour les tests : ils n’ajoutent aucun coût de stockage et existent uniquement pour le test unitaire.

-- models/test_helpers/test_calculate_discount.sql
{{ config(materialized='ephemeral') }}
select
order_value,
customer_tier,
{{ calculate_discount('order_value', 'customer_tier') }} as discount_amount
from {{ ref('test_source') }}
unit_tests:
- name: test_calculate_discount_macro
model: test_calculate_discount
given:
- input: ref('test_source')
format: sql
rows: |
select 100.00 as order_value, 'gold' as customer_tier
union all
select 100.00 as order_value, 'silver' as customer_tier
union all
select 100.00 as order_value, 'bronze' as customer_tier
expect:
rows:
- {order_value: 100.00, customer_tier: "gold", discount_amount: 20.00}
- {order_value: 100.00, customer_tier: "silver", discount_amount: 10.00}
- {order_value: 100.00, customer_tier: "bronze", discount_amount: 5.00}

Le modèle wrapper fait trois choses :

  1. Référence une source de test (également éphémère) pour fournir des données d’entrée
  2. Appelle la macro testée (calculate_discount)
  3. Produit le résultat pour comparaison

Le test vérifie ensuite que calculate_discount retourne :

  • 20% de remise (20€) pour les clients gold
  • 10% de remise (10€) pour les clients silver
  • 5% de remise (5€) pour les clients bronze

Si quelqu’un modifie la logique de la macro, ce test détectera les changements non intentionnels. Stockez ces modèles helpers de test dans un répertoire test_helpers/ dédié et excluez-les des builds de production.

Organiser les tests à grande échelle

Au fur et à mesure que votre projet dbt grandit, votre suite de tests aussi. Un projet mature pourrait avoir des centaines de tests unitaires sur des dizaines de modèles. Sans organisation, exécuter les tests devient lent et sélectionner les tests pertinents devient fastidieux.

Utiliser les tags stratégiquement

Les tags vous permettent de catégoriser les tests et d’exécuter des sous-ensembles de manière sélective. Considérez une stratégie de tagging basée sur :

  • Criticité : critical pour les tests qui bloquent le déploiement vs. nice-to-have pour les extras
  • Domaine : finance, marketing, product pour correspondre à vos domaines métier
  • Type de test : regression, edge-case, smoke-test
unit_tests:
- name: test_mrt_finance_orders_revenue_calculation
model: mrt__finance__orders
config:
tags: ["critical", "finance", "regression"]
# ...
- name: test_mrt_finance_orders_empty_cart
model: mrt__finance__orders
config:
tags: ["edge-case"]
# ...

Maintenant vous pouvez exécuter des sous-ensembles :

Terminal window
# Tests critiques seulement (pour un feedback CI rapide sur chaque PR)
dbt test --select tag:critical,test_type:unit
# Suite de régression complète (chaque nuit ou avant les releases)
dbt test --select tag:regression,test_type:unit
# Tests spécifiques au domaine (quand on travaille sur les modèles finance)
dbt test --select tag:finance,test_type:unit

Une stratégie CI courante :

  1. Checks PR : Exécuter seulement les tests tag:critical (feedback rapide, 1-2 minutes)
  2. Merge sur main : Exécuter tous les tests unitaires (validation complète, 5-10 minutes)
  3. Déploiement production : Exécuter les tests de données (valider les données réelles, séparé des tests unitaires)

Référence rapide de la bibliothèque de patterns

PatternTechnique cléExemple
Incrémental full-refreshis_incremental: falseSection 1
Incrémental mergethis + is_incremental: trueSection 1
Plages de dates SCD2Tester les modèles dérivésSection 2
Fonctions de fenêtrageLignes d’entrée ordonnéesSection 3
Toutes les branches CASEValeurs limitesSection 3
Tables videsformat: sql avec WHERE falseSection 5
Gestion des nullsLignes null explicitesSection 5
Test de macroModèle wrapper éphémèreSection 6

Points clés à retenir

Tester unitairement les modèles dbt ne consiste pas à atteindre une couverture de 100%—il s’agit de tester la logique qui compte le plus. Concentrez vos efforts sur :

  1. Modèles incrémentaux : Testez les chemins full-refresh et incrémental. Le bloc expect montre ce qui est inséré, pas l’état final de la table.
  2. Fonctions de fenêtrage : Concevez des données de test qui valident le partitionnement, l’ordonnancement et le cadrage. Utilisez des lignes source dans le désordre pour vérifier que le tri fonctionne correctement.
  3. Logique métier : Testez les valeurs limites dans les instructions CASE WHEN. Testez explicitement la gestion des nulls. Documentez le comportement attendu à travers les tests.
  4. Analytics marketing : L’attribution, la sessionisation et les funnels sont des modèles à enjeux élevés où les bugs impactent directement les décisions business. Investissez dans des tests complets.
  5. Cas limites : Les tables vides, les valeurs null et les limites de dates causent de vrais problèmes en production. Les tests unitaires prouvent que votre modèle les gère gracieusement.

Les patterns de cet article sont des points de départ. Adaptez-les à vos modèles et logique métier spécifiques. Le meilleur test est celui qui attrape un bug avant qu’il n’atteigne la production.


Et ensuite

Vous avez maintenant une bibliothèque de patterns pour tester unitairement les modèles dbt du monde réel. Mais les tests unitaires ne sont qu’un outil dans la boîte à outils de test. Ils valident la logique de transformation avec des données mockées—mais qu’en est-il de la validation des données réelles dans votre warehouse ?

La Partie 3 prend du recul au niveau stratégique : quand devriez-vous utiliser les tests unitaires versus les tests de données ? Qu’en est-il des packages externes comme dbt-expectations et Elementary ? Nous construirons un cadre de décision pour vous aider à choisir la bonne approche de test pour chaque scénario—et éviter à la fois le sous-test et le sur-test.