ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Mocker les dépendances dans les tests unitaires dbt

Comment mocker les refs, sources, macros, variables et le mot-clé 'this' dans les tests unitaires dbt — avec des patterns pour les modèles multi-jointures et les overrides incrémentaux.

Planté
dbttesting

Les tests unitaires isolent la logique de transformation en remplaçant chaque dépendance — modèles amont, sources, macros, variables — par des valeurs contrôlées. Le test vérifie uniquement la transformation, pas la qualité des données amont ou les différences d’environnement des macros.

Mocker plusieurs refs et sources

La plupart des modèles joignent plusieurs tables. Vous devez fournir des données mock pour chaque dépendance que votre modèle référence :

unit_tests:
- name: test_order_enrichment
model: mrt__sales__orders
given:
- input: ref('base__shopify__orders')
rows:
- {order_id: 1, customer_id: 100, product_id: 500, quantity: 2}
- input: ref('int__customers_enriched')
rows:
- {customer_id: 100, customer_segment: "enterprise"}
- input: ref('base__shopify__products')
rows:
- {product_id: 500, unit_price: 49.99}
expect:
rows:
- {order_id: 1, customer_segment: "enterprise", order_value: 99.98}

L’insight clé : vous n’avez besoin d’inclure que les colonnes que votre modèle référence réellement. Si int__customers_enriched a 50 colonnes mais que votre modèle ne joint que sur customer_id et sélectionne customer_segment, ces deux colonnes suffisent dans votre mock. dbt remplit les valeurs par défaut pour tout le reste.

C’est une commodité significative. Sans cela, mocker un modèle qui joint trois tables de 30 colonnes nécessiterait d’écrire 90 colonnes par ligne de test. Avec ça, vous avez généralement besoin de 3-5 colonnes par entrée — juste les clés de jointure et les champs que votre logique transforme réellement.

Pour les sources, utilisez source() au lieu de ref() :

given:
- input: source('salesforce', 'accounts')
rows:
- {id: "001ABC", name: "Acme Corp", type: "Customer"}

La syntaxe reflète exactement la manière dont vous référencez les sources dans le SQL de votre modèle : source('nom_schema', 'nom_table').

Surcharger les macros

Beaucoup de modèles utilisent des macros pour les timestamps, la logique conditionnelle ou les fonctions utilitaires. Celles-ci introduisent du non-déterminisme — un test qui dépend de current_timestamp() produira des résultats différents selon le moment où il s’exécute. Surchargez-les pour des tests déterministes :

unit_tests:
- name: test_created_date_logic
model: mrt__core__customers
overrides:
macros:
dbt_utils.current_timestamp: "'2024-06-15 10:00:00'"
given:
- input: ref('base__crm__customers')
rows:
- {customer_id: 1, created_at: "2024-06-01 09:00:00"}
expect:
rows:
- {customer_id: 1, is_recent: true}

Le bloc overrides.macros remplace la sortie de la macro par une valeur statique pendant le test. La clé est le nom complet de la macro (package + nom de la macro), et la valeur est l’expression SQL qui doit la remplacer.

C’est particulièrement important pour la logique temporelle. Un modèle qui calcule « ce client est-il actif dans les 30 derniers jours » en utilisant current_timestamp() se comportera différemment selon les jours. En fixant le timestamp, vous rendez le test déterministe : created_at du 1er juin avec un instant courant du 15 juin est toujours 14 jours plus tôt, toujours dans les 30 jours, toujours is_recent = true.

Surcharger les variables

Si votre modèle utilise var() pour des seuils configurables ou des feature flags, surchargez-les dans le test :

unit_tests:
- name: test_with_custom_threshold
model: mrt__core__customers
overrides:
vars:
days_threshold: 30
given:
- input: ref('base__crm__customers')
rows:
- {customer_id: 1, last_order_date: "2024-05-20"}
expect:
rows:
- {customer_id: 1, is_active: true}

Les overrides de variables sont plus simples que les overrides de macros — juste des paires clé-valeur dans le bloc overrides.vars. Le nom de la variable n’a pas besoin d’un qualificateur de package.

C’est précieux pour tester les conditions aux limites. Si days_threshold est à 90 par défaut, vous pouvez écrire un test avec days_threshold: 30 et un autre avec days_threshold: 7 pour vérifier que la logique gère correctement les différents seuils sans modifier le code du modèle.

Le mot-clé this pour les modèles incrémentaux

Pour les modèles incrémentaux, this représente l’état actuel de la table cible — les lignes qui existent déjà avant l’exécution incrémentale. C’est le pattern de mocking le plus puissant car il vous permet de tester explicitement la logique de merge :

unit_tests:
- name: test_incremental_dedup
model: int__events_deduplicated
overrides:
macros:
is_incremental: true
given:
- input: ref('base__ga4__events')
rows:
- {event_id: 1, event_time: "2024-06-15"}
- {event_id: 2, event_time: "2024-06-16"}
- input: this
rows:
- {event_id: 1, event_time: "2024-06-15"} # Existe déjà
expect:
rows:
- {event_id: 2, event_time: "2024-06-16"} # Seule la nouvelle ligne

Deux choses se passent ici :

  1. overrides.macros.is_incremental: true force le modèle en mode incrémental. Sans cela, le bloc {% if is_incremental() %} dans votre modèle s’évaluerait à false, et le test s’exécuterait sur le chemin full-refresh au lieu du chemin incrémental.

  2. input: this mocke l’état actuel de la table. Les lignes que vous mettez ici représentent ce qui est déjà dans la table cible depuis les exécutions précédentes.

Ensemble, ils vous permettent de vérifier le comportement exact de votre logique de merge : déduplique-t-elle correctement ? Gère-t-elle les données arrivant en retard ? Met-elle à jour les enregistrements existants quand elle le doit ? Laisse-t-elle les enregistrements existants intacts quand elle le doit ?

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. Un test unitaire qui mocke this détecte le bug avant qu’il n’atteigne la production.

Que se passe-t-il si vous oubliez une dépendance

Si votre modèle référence un ref() ou source() que vous n’avez pas mocké dans le bloc given, dbt lancera une erreur « node not found » pendant la compilation. La solution est simple : ajoutez l’entrée manquante à given, même si vous n’avez besoin que de colonnes minimales.

Pour les entrées que votre modèle joint mais dont vous n’avez pas besoin pour le scénario spécifique que vous testez, fournissez un mock minimal :

given:
# L'entrée dont votre test a besoin
- input: ref('base__shopify__orders')
rows:
- {order_id: 1, quantity: 2, unit_price: 50.00}
# Une entrée que votre modèle joint mais que ce test ne concerne pas
- input: ref('base__shopify__customers')
rows:
- {customer_id: 1} # Juste la clé de jointure, mock minimal viable

La règle : chaque ref(), source() ou this dans le SQL de votre modèle a besoin d’une entrée correspondante dans given. Vous pouvez minimiser ce que vous y mettez, mais l’entrée doit exister.

Conseils pratiques

Commencez par le cas nominal. Mockez d’abord le scénario le plus simple — une ligne par entrée, la sortie attendue correspond à la logique basique. Une fois que ça passe, ajoutez des lignes pour les cas limites : nulls, valeurs aux limites, chaînes vides, nombres négatifs.

Nommez vos lignes de test de manière sémantique. Vous ne pouvez pas ajouter de commentaires à l’intérieur des lignes dict YAML, mais vous pouvez utiliser des IDs qui communiquent l’intention : {customer_id: 1, ...} pour le cas normal, {customer_id: 99, ...} pour le cas limite. Ou ajoutez un champ description au test lui-même expliquant ce que chaque ligne teste.

Ne sur-mockez pas. Si votre modèle joint cinq tables mais que la logique que vous testez n’en implique que deux, les trois autres entrées n’ont besoin que des clés de jointure et rien d’autre. Gardez le focus sur ce que le test vérifie réellement.

Testez un comportement par test. Plutôt qu’un seul test massif avec 20 lignes couvrant chaque scénario, écrivez des tests séparés pour chaque comportement : test_discount_calculation, test_null_quantity_handling, test_negative_amount_edge_case. Cela rend les échecs immédiatement diagnostiques — vous savez exactement ce qui est cassé.