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_atfrom {{ ref('base__ga4__events') }}
{% if is_incremental() %}where event_timestamp > (select max(event_timestamp) from {{ this }}){% endif %}Ce modèle a deux comportements :
- Full refresh : Traiter tous les événements de la table source
- 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 :
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: falsedans les overrides, simulant undbt 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
thiscontient un événement avec le timestamp2024-06-01 10:00:00 - La clause
WHEREde 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_atfrom {{ 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
thismontre 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" --emptypour 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 :
- 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
- Tester les modèles qui consomment la sortie du snapshot — valider vos calculs de plages de dates SCD2 et les flags
is_current - 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 (
id→user_id,name→user_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.sqlselect 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_currentfrom {{ 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 :
- Calcul des plages de dates : Le premier enregistrement d’Alice doit avoir
valid_to = "2024-06-15"(levalid_fromde la version suivante), tandis que son deuxième enregistrement utilise la date sentinelle du futur lointain - Logique du flag current : Seule la version la plus récente de chaque utilisateur doit avoir
is_current: true - Gestion d’une seule version : Bob n’a qu’un seul enregistrement, donc il est automatiquement courant avec le
valid_tosentinelle - 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.sqlselect 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_viewsfrom {{ 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 :
-
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 leORDER BY event_timestampfonctionne réellement—si vous ordonnez accidentellement parevent_idà la place, le test échouerait. -
Plusieurs utilisateurs : Inclure User 2 confirme que le partitionnement fonctionne correctement. Les événements de User 1 ne devraient pas affecter le
session_event_numberou lalanding_pagede User 2. -
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, sansROWS 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 >= 1000retourne NULL, pas FALSE - ELSE implicite : S’appuyer sur le
ELSE NULLpar 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.sqlselect 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 segmentfrom {{ 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.sqlwith 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_countfrom sessionizedgroup by 1, 2, 3unit_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 minutesLe 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.sqlselect 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_timestampfrom {{ 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_idunit_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 :
- Premier contact via les publicités Facebook payantes (1er juin)
- Retour via la recherche organique Google (5 juin)
- 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 :
| User | page_view | add_to_cart | begin_checkout | purchase |
|---|---|---|---|---|
| 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 logiqueCe 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_amountfrom {{ 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 :
- Référence une source de test (également éphémère) pour fournir des données d’entrée
- Appelle la macro testée (
calculate_discount) - 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é :
criticalpour les tests qui bloquent le déploiement vs.nice-to-havepour les extras - Domaine :
finance,marketing,productpour 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 :
# 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:unitUne stratégie CI courante :
- Checks PR : Exécuter seulement les tests
tag:critical(feedback rapide, 1-2 minutes) - Merge sur main : Exécuter tous les tests unitaires (validation complète, 5-10 minutes)
- 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
| Pattern | Technique clé | Exemple |
|---|---|---|
| Incrémental full-refresh | is_incremental: false | Section 1 |
| Incrémental merge | this + is_incremental: true | Section 1 |
| Plages de dates SCD2 | Tester les modèles dérivés | Section 2 |
| Fonctions de fenêtrage | Lignes d’entrée ordonnées | Section 3 |
| Toutes les branches CASE | Valeurs limites | Section 3 |
| Tables vides | format: sql avec WHERE false | Section 5 |
| Gestion des nulls | Lignes null explicites | Section 5 |
| Test de macro | Modèle wrapper éphémère | Section 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 :
- Modèles incrémentaux : Testez les chemins full-refresh et incrémental. Le bloc
expectmontre ce qui est inséré, pas l’état final de la table. - 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.
- 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.
- 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.
- 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.