ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Anti-patterns de test dbt

Quatre erreurs de test courantes dans les projets dbt — over-testing, couverture happy-path uniquement, seuils qui dérivent et test des fonctions de l'entrepôt — et quoi faire à la place.

Planté
dbttestingdata quality

Quatre erreurs de test courantes dans les projets dbt — over-testing avec des tests unitaires, couverture happy-path uniquement, seuils codés en dur qui dérivent et test des fonctions de l’entrepôt — chacune avec un correctif spécifique.

Anti-pattern 1 : Tout tester avec des tests unitaires

L’instinct venu du génie logiciel est compréhensible : viser une couverture de tests élevée. Dans dbt, cet instinct conduit rapidement à des rendements décroissants.

Un modèle qui sélectionne cinq colonnes depuis une table source et les renomme n’a rien à tester unitairement. Le SQL est SELECT column_a AS better_name FROM source_table. Il n’y a pas de logique. Il n’y a pas de cas limite. Un test unitaire qui mock column_a = 'hello' et attend better_name = 'hello' prouve que dbt peut exécuter une instruction SELECT — ce qui n’est pas en question.

La même chose s’applique aux agrégations simples. SUM(revenue), COUNT(DISTINCT customer_id), MAX(created_at) — ce sont des fonctions d’entrepôt. BigQuery les teste de façon extensive. Votre budget de tests unitaires devrait aller aux 5 à 10% de modèles où la logique de transformation est suffisamment complexe pour que les bugs soient genuinement plausibles.

Quoi faire à la place : Appliquez le cadre de décision. Si la logique n’est pas suffisamment complexe pour avoir plausiblement des bugs, sautez le test unitaire. Ajoutez un test de données not_null sur la colonne de sortie à la place — c’est moins coûteux, s’exécute sur des données réelles et détecte une classe plus large de problèmes.

Là où les tests unitaires valent leur poids : Logique CASE WHEN multi-branches, fonctions fenêtre avec des définitions de frame spécifiques, calculs de date avec des conditions limites, parsing de chaînes avec regex, logique de merge incrémentiel. Ce sont les modèles où un bug subtil se cache pendant des semaines avant que quelqu’un remarque que les chiffres sont incorrects.

Anti-pattern 2 : Ne tester que les happy paths

Vos tests unitaires devraient vous mettre mal à l’aise. Si chaque ligne de fixture représente une entrée propre et bien formée qui suit le chemin attendu, vous testez que votre code fonctionne quand tout se passe bien. C’est le cas facile. Le cas difficile est ce qui se passe quand les choses tournent mal.

Les entrées qui cassent les transformations SQL sont prévisibles :

  • Les valeurs NULL là où vous ne les attendez pas. Un CASE WHEN status = 'active' ne correspond pas à NULL — il tombe dans la branche ELSE. Est-ce ce que vous souhaitiez ?
  • Les chaînes vides qui se comportent différemment des NULL dans les opérations sur chaînes. CONCAT('prefix_', NULL) renvoie NULL, mais CONCAT('prefix_', '') renvoie 'prefix_'.
  • Les valeurs limites aux bords de vos conditions CASE WHEN. Si votre logique dit WHEN amount > 100 THEN 'high', que se passe-t-il exactement à 100 ?
  • Les valeurs zéro dans les dénominateurs. Un calcul de revenu par client où customer_count = 0 produit une erreur de division par zéro ou une valeur infinie, selon l’entrepôt.
  • Les valeurs négatives que votre système source « n’envoie jamais » jusqu’à ce qu’il le fasse. Un calcul de remise qui suppose des quantités positives produira des remises négatives quand la quantité est négative.
unit_tests:
- name: test_discount_handles_edge_cases
model: mrt__finance__orders
given:
- input: ref('base__shopify__orders')
rows:
- {order_id: 1, quantity: 3, unit_price: 100, discount_rate: 0.1} # happy path
- {order_id: 2, quantity: -1, unit_price: 100, discount_rate: 0.1} # quantité négative
- {order_id: 3, quantity: 0, unit_price: 100, discount_rate: 0.1} # quantité zéro
- {order_id: 4, quantity: 3, unit_price: 100, discount_rate: null} # remise null
- {order_id: 5, quantity: null, unit_price: 100, discount_rate: 0.1} # quantité null
expect:
rows:
- {order_id: 1, discount_amount: 30}
- {order_id: 2, discount_amount: 0} # plafonné à zéro
- {order_id: 3, discount_amount: 0}
- {order_id: 4, discount_amount: 0} # pas de taux de remise = pas de remise
- {order_id: 5, discount_amount: 0} # quantité null = pas de remise

Quoi faire à la place : Pour chaque test unitaire, incluez au moins une ligne de fixture avec des entrées NULL, une avec des valeurs limites et une avec une entrée que votre système source « n’envoie jamais ». Ces trois lignes supplémentaires par test détectent les bugs qui atteignent réellement la production.

Le meilleur déclencheur pour ajouter des lignes de cas limites : un bug en production. Quand un bug est découvert, reproduisez-le comme fixture de test unitaire avant de corriger le code. Vérifiez que le test échoue. Corrigez le modèle. Vérifiez que le test réussit. Cela construit une suite de régression de manière organique, focalisée sur les modes d’échec réels plutôt qu’imaginaires.

Anti-pattern 3 : Coder en dur des seuils qui dérivent

Celui-ci est subtil parce qu’il fonctionne parfaitement lors du premier déploiement :

- dbt_expectations.expect_table_row_count_to_be_between:
min_value: 1000
max_value: 2000

Votre table a 1 500 lignes. Le test réussit. Six mois plus tard, votre table a 2 100 lignes parce que l’activité croît. Le test échoue. Pas parce que quelque chose ne va pas — parce que votre seuil est obsolète.

L’équipe met à jour le max à 3 000. Six mois de plus, un autre échec, une autre mise à jour manuelle. Finalement, quelqu’un le fixe à 10 000 000 par frustration, et le test cesse de détecter quoi que ce soit.

C’est le tapis roulant de maintenance des seuils. Tout test avec des bornes codées en dur sur une métrique qui change naturellement au fil du temps nécessitera soit des mises à jour constantes, soit sera élargi au point d’inutilité.

Quoi faire à la place : Pour les métriques qui dérivent dans le temps (comptages de lignes, valeurs agrégées, statistiques de distribution), utilisez la détection d’anomalies d’Elementary plutôt que des seuils statiques.

# Au lieu de bornes codées en dur :
- dbt_expectations.expect_table_row_count_to_be_between:
min_value: 1000
max_value: 2000
# Utilisez la détection adaptative :
- elementary.volume_anomalies:
timestamp_column: created_at
training_period:
period: day
count: 30

Elementary apprend ce qui est normal depuis vos données historiques et alerte quand les métriques dévient au-delà des plages attendues. Pas de seuils codés en dur à maintenir. Pas de mises à jour manuelles au fur et à mesure que les données croissent.

Quand les seuils statiques restent pertinents : Les valeurs qui ont des bornes fixes et genuines. Le revenu doit être non négatif. Les taux de conversion doivent être entre 0 et 1. Une colonne de pourcentage doit être entre 0 et 100. Ces bornes ne dérivent pas — ce sont des contraintes de domaine. Utilisez dbt_expectations.expect_column_values_to_be_between pour celles-ci.

Anti-pattern 4 : Tester les fonctions de l’entrepôt

Ne testez pas unitairement SUM(). Ne testez pas unitairement DATE_TRUNC(). Ne testez pas unitairement COALESCE(). Ce sont des fonctions d’entrepôt que BigQuery, Snowflake et tous les autres entrepôts de données testent de façon extensive dans leurs propres suites de tests. Elles fonctionnent. Votre test unitaire prouvant que SUM(10, 20, 30) = 60 teste BigQuery, pas votre code.

La frontière entre « tester l’entrepôt » et « tester votre logique » peut être floue. Voici la règle de base : si vous remplaciez vos valeurs spécifiques par des valeurs différentes et que le test aurait encore du sens, vous testez la fonction d’entrepôt. Si le test n’a de sens qu’avec vos valeurs spécifiques en raison de la logique métier autour d’elles, vous testez votre logique.

# Tester l'entrepôt (ne faites pas ça) :
unit_tests:
- name: test_sum_works
model: mrt__finance__monthly_revenue
given:
- input: ref('base__orders')
rows:
- {month: '2024-01', revenue: 100}
- {month: '2024-01', revenue: 200}
expect:
rows:
- {month: '2024-01', total_revenue: 300}
# Tester votre logique (faites ça) :
unit_tests:
- name: test_revenue_excludes_cancelled_refunds
model: mrt__finance__monthly_revenue
given:
- input: ref('base__orders')
rows:
- {month: '2024-01', revenue: 100, status: 'completed'}
- {month: '2024-01', revenue: 200, status: 'cancelled'}
- {month: '2024-01', revenue: 50, status: 'refunded', refund_type: 'partial'}
expect:
rows:
- {month: '2024-01', total_revenue: 50} # 100 - 50 remboursement partiel

Le premier test prouve que SUM fonctionne. Le deuxième test prouve que votre logique de filtrage et de remboursement produit le chiffre de revenu correct. Le deuxième test a de la valeur parce que les règles métier autour de ce qui compte comme revenu sont complexes et pourraient plausiblement être incorrectes.

Quoi faire à la place : Si le modèle ne contient aucune décision ou branche qui pourrait plausiblement être incorrecte — s’il est une application directe des fonctions d’entrepôt à des entrées propres — sautez le test unitaire. Utilisez des tests de données sur la sortie à la place : not_null sur la colonne agrégée, expect_column_values_to_be_between pour des bornes de cohérence, unique sur le grain.