ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Quand écrire des tests unitaires dbt

Critères de décision spécifiques pour savoir où les tests unitaires natifs dbt apportent de la valeur — scénarios de logique complexe, le pattern d'override pour les modèles incrémentaux, et ce qu'il faut ignorer.

Planté
dbttestingdata quality

Les tests unitaires natifs dbt (introduits en version 1.8) sont des outils à fort investissement et haute précision. Ils valent la peine d’être écrits pour environ 1 % des colonnes — celles où la logique de transformation est assez complexe pour qu’un bug soit réellement plausible et que les tests de données seuls ne détecteraient pas la régression. La question est de savoir quels scénarios spécifiques les justifient.

Les quatre scénarios qui justifient les tests unitaires

Parsing de chaînes complexe et regex

Si vous extrayez des domaines depuis des adresses email, parsez des paramètres UTM depuis des URLs ou nettoyez des champs de texte libre désorganisés, les cas limites se multiplient rapidement. Un extracteur de domaine d’email doit gérer les sous-domaines, les TLDs inhabituels et les entrées malformées. Un parser UTM doit gérer les caractères encodés, les paramètres manquants et les variations d’ordre des paramètres.

Les tests de données ne détectent pas ces cas limites. Un test not_null sur la colonne de sortie passe même lorsque le parser retourne silencieusement NULL pour un format d’entrée inhabituel. Un contrôle de format basé sur regex sur la sortie valide la forme du résultat, pas si la logique d’extraction est correcte.

Les tests unitaires vous permettent de définir explicitement les cas limites et de vérifier chacun :

unit_tests:
- name: test_utm_source_extraction
model: int__sessions__attributed
given:
- input: ref('base__ga4__events')
rows:
- {session_id: 1, page_referrer: "https://example.com?utm_source=google&utm_medium=cpc"}
- {session_id: 2, page_referrer: "https://example.com?UTM_SOURCE=facebook"} # majuscules
- {session_id: 3, page_referrer: "https://example.com"} # sans UTM
- {session_id: 4, page_referrer: null} # referrer null
expect:
rows:
- {session_id: 1, utm_source: "google"}
- {session_id: 2, utm_source: "facebook"}
- {session_id: 3, utm_source: null}
- {session_id: 4, utm_source: null}

Calculs de dates avec cas limites

Les mappings d’années fiscales, les calculs de jours ouvrés et les conversions de fuseaux horaires sont réputés pour leurs cas limites. Une année fiscale qui commence en avril semble simple jusqu’à ce que vous traitiez une date au 31 mars proche de minuit dans un fuseau horaire UTC+5. Un calcul de « jours depuis la dernière commande » se comporte différemment aux limites de mois, d’année, et pour les clients sans commande précédente.

Définissez les cas limites comme des fixtures de tests unitaires et vous avez documenté de manière permanente le comportement attendu tout en détectant automatiquement les régressions :

unit_tests:
- name: test_fiscal_year_assignment
model: int__finance__fiscal_calendar
given:
- input: ref('base__dates__spine')
rows:
- {date_day: '2024-03-31'} # dernier jour de l'année fiscale
- {date_day: '2024-04-01'} # premier jour de la nouvelle année fiscale
- {date_day: '2024-12-31'} # fin d'année calendaire, milieu d'année fiscale
- {date_day: '2025-03-31'} # dernier jour de la prochaine année fiscale
expect:
rows:
- {date_day: '2024-03-31', fiscal_year: 2024, fiscal_quarter: 4}
- {date_day: '2024-04-01', fiscal_year: 2025, fiscal_quarter: 1}
- {date_day: '2024-12-31', fiscal_year: 2025, fiscal_quarter: 3}
- {date_day: '2025-03-31', fiscal_year: 2025, fiscal_quarter: 4}

Logique CASE WHEN à branches multiples

Une colonne dont la valeur dépend de cinq conditions avec des chevauchements entre catégories est un risque de maintenance. Lorsque quelqu’un modifie le CASE WHEN pour ajouter une sixième catégorie ou ajuste un ordre de priorité, il doit vérifier que le comportement existant n’a pas changé — mais il n’y a pas de vérification automatisée à moins que des tests unitaires soient en place.

Les tests unitaires documentent le comportement attendu par branche et détectent les régressions lorsque la logique change. Ils sont particulièrement précieux sur les modèles de segmentation client, de scoring de leads et de classification des commandes où les règles métier changent régulièrement.

unit_tests:
- name: test_customer_segment_assignment
model: mrt__sales__customers
given:
- input: ref('int__customers__aggregated')
rows:
- {customer_id: 1, lifetime_value: 5000, order_count: 12, days_since_last_order: 15}
- {customer_id: 2, lifetime_value: 5000, order_count: 12, days_since_last_order: 200}
- {customer_id: 3, lifetime_value: 50, order_count: 1, days_since_last_order: 10}
- {customer_id: 4, lifetime_value: 0, order_count: 0, days_since_last_order: null}
expect:
rows:
- {customer_id: 1, segment: "champion"}
- {customer_id: 2, segment: "at_risk"}
- {customer_id: 3, segment: "new"}
- {customer_id: 4, segment: "prospect"}

Chaque ligne teste une branche différente. Si une modification future reclasse accidentellement les champions comme à risque, le test échoue immédiatement à la prochaine exécution CI.

Fonctions de fenêtre

Le classement, les totaux cumulés et les calculs lag/lead sont notoirement délicats. Les frames de fenêtre en particulier — ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW vs ROWS BETWEEN 1 PRECEDING AND CURRENT ROW — produisent des résultats différents et la différence n’est pas toujours évidente à la lecture rapide du code.

Mocker un petit jeu de données contrôlé vous permet de vérifier que la fenêtre se comporte exactement comme prévu :

unit_tests:
- name: test_revenue_running_total
model: mrt__finance__monthly_revenue
given:
- input: ref('int__orders__monthly')
rows:
- {month: '2024-01', revenue: 1000}
- {month: '2024-02', revenue: 1500}
- {month: '2024-03', revenue: 800}
expect:
rows:
- {month: '2024-01', revenue: 1000, cumulative_revenue: 1000}
- {month: '2024-02', revenue: 1500, cumulative_revenue: 2500}
- {month: '2024-03', revenue: 800, cumulative_revenue: 3300}

Le pattern d’override pour les modèles incrémentaux

Les tests unitaires deviennent particulièrement précieux pour les modèles incrémentaux car la correction de la logique de merge dépend de l’état existant de la table — quelque chose que les tests de données en production ne peuvent pas facilement reproduire. La macro is_incremental() et this (la table courante) peuvent tous deux être surchargés :

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

Le overrides.macros.is_incremental: true force le modèle en mode incrémental. Le input: this mocke l’état actuel de la table. Ensemble, ils vous permettent de tester le chemin de merge explicitement — en vérifiant que la logique de déduplication sélectionne le bon enregistrement, que les nouveaux enregistrements sont correctement insérés, et que les enregistrements existants qui ne doivent pas être mis à jour ne le sont pas.

Les bugs de merge incrémental s’accumulent silencieusement entre les exécutions et peuvent nécessiter de remplir plusieurs mois de données avant d’être détectés. Les tests unitaires avec un état this mocké détectent ces bugs avant qu’ils n’atteignent la production.

Ce qu’il faut ignorer

Le consensus de la communauté est d’environ 1 % de colonnes qui justifient des tests unitaires. N’essayez pas de tout tester unitairement.

Ignorez les colonnes passthrough. Si une colonne sélectionne simplement source.column_name sans transformation, il n’y a rien à tester. L’entrepôt récupère soit correctement la valeur, soit pas — ce n’est pas quelque chose que les tests unitaires SQL vérifient.

Ignorez les agrégations simples. Un SUM(revenue) ou COUNT(orders) n’a pas de cas limites qui valent la peine d’être testés. Ajoutez plutôt un test de données not_null.

Ignorez les modèles que vous avez déjà soigneusement testés via des tests de données. Si vos tests unique + not_null plus quelques vérifications de plage vous donnent confiance dans un modèle, ajouter des tests unitaires ne réduit pas matériellement le risque.

N’essayez pas de couvrir toutes les combinaisons d’entrée possibles. Les tests unitaires documentent l’intention et détectent les régressions ; ils ne sont pas des preuves exhaustives. Choisissez 4-6 cas représentatifs par modèle, en vous concentrant sur les cas limites les plus susceptibles de casser — valeurs aux limites, nulls, entrées inattendues.

Friction pratique

La principale friction dans l’écriture de tests unitaires est la « comptabilité YAML » — la création manuelle de fixtures pour des modèles avec de nombreuses colonnes d’entrée. Un modèle qui joint trois sources, chacune avec 20 colonnes, nécessite des fixtures qui spécifient chaque colonne pertinente dans les trois entrées.

La génération de fixtures assistée par IA aide significativement ici. Décrivez le scénario que vous souhaitez tester, collez le SQL du modèle et demandez le YAML de fixture. C’est une bonne tâche pour ce type de génération structurée.

Pour les exécutions en production, excluez toujours les tests unitaires :

Terminal window
dbt build --exclude-resource-type unit_test

Les tests unitaires utilisent des données mockées et n’ont aucune valeur en production. Ils appartiennent au CI et au développement local uniquement. Les exécuter en production gaspille du compute et peut créer de la confusion lorsque des tests qui n’ont jamais vu que des données propres rencontrent la complexité du monde réel.

Deux déclencheurs clairs pour ajouter un test unitaire : corriger un bug de production causé par une logique de transformation incorrecte (prévention des régressions), ou implémenter une logique métier complexe en mode test-first. Dans les deux cas, l’objectif est de tester des modes de défaillance anticipés spécifiques, pas des pourcentages de couverture.