Adrienne Vermorel
Unit Testing dans dbt 1.8+ : Guide d'implémentation complet
Ceci est la Partie 1 d’une série en 3 parties sur les tests unitaires dbt. Cet article couvre les fondamentaux : syntaxe, configuration, considérations BigQuery et intégration CI/CD.
Vous avez écrit un modèle avec une logique métier complexe—peut-être un calcul de valeur vie client, un algorithme de sessionisation, ou un modèle d’attribution. Ça fonctionne en développement. Ça passe la code review. Puis trois semaines plus tard, quelqu’un découvre que des cas limites produisent des résultats erronés en production.
Avant dbt 1.8, détecter ces bugs de logique signifiait soit construire des frameworks de test élaborés avec des packages externes, soit découvrir les problèmes après que les mauvaises données se soient déjà propagées en aval. Cela a changé en mai 2024 quand dbt a introduit les tests unitaires natifs.
Les tests unitaires vous permettent de valider la logique de transformation avec des entrées mockées et statiques—avant que vos modèles ne touchent les données de production. Dans ce guide, nous allons construire une configuration complète de tests unitaires de zéro, avec une attention particulière aux considérations spécifiques à BigQuery.
Comprendre les tests unitaires dans dbt
Un test unitaire répond à une question simple : “Si je fournis à ce modèle exactement ces lignes en entrée, produit-il exactement ces lignes en sortie ?”
Contrairement aux tests de données (qui s’exécutent sur les données réelles du warehouse après matérialisation), les tests unitaires s’exécutent sur des données mockées pendant la phase de build. Ils testent votre logique, pas vos données.
# Un test unitaire simpleunit_tests: - name: test_is_valid_email model: mrt__core__customers given: - input: ref('base__crm__customers') rows: - {customer_id: 1, email: "valid@example.com"} - {customer_id: 2, email: "invalid-email"} expect: rows: - {customer_id: 1, is_valid_email: true} - {customer_id: 2, is_valid_email: false}Ce test ne se soucie pas de ce qui se trouve dans votre vraie table base__crm__customers. Il crée des données mockées, exécute le SQL de votre modèle contre elles, et vérifie si la sortie correspond aux attentes.
Quand vous exécutez dbt test --select test_type:unit, dbt n’exécute que les tests unitaires. Quand vous exécutez dbt test --select test_type:data, il n’exécute que les tests de données (tests génériques et singuliers). Cette séparation est cruciale pour les workflows CI/CD, comme nous le verrons plus tard.
Syntaxe YAML en détail
Éléments requis
Chaque test unitaire a besoin de quatre choses : un nom, un modèle cible, des données d’entrée et une sortie attendue.
unit_tests: - name: test_customer_status_logic # Identifiant unique model: mrt__core__customers # Modèle testé given: # Entrées mockées - input: ref('base__crm__customers') rows: - {customer_id: 1, status: "active"} expect: # Sortie attendue rows: - {customer_id: 1, is_active: true}La section given accepte ref() pour les modèles, source() pour les sources, et this pour les auto-références dans les modèles incrémentaux. Vous n’avez besoin de spécifier que les colonnes que votre logique utilise réellement ; dbt gère le reste.
Formats d’entrée
dbt supporte trois formats pour définir les données de test : dict, csv et sql.
Le format dict (par défaut) est le plus lisible pour les petits jeux de données :
given: - input: ref('base__shopify__orders') format: dict rows: - {order_id: 1, amount: 100.00, status: "completed"} - {order_id: 2, amount: 50.00, status: "pending"}Le format CSV fonctionne bien pour les jeux de données plus volumineux ou quand vous voulez des fichiers de fixtures externes :
given: - input: ref('base__shopify__orders') format: csv rows: | order_id,amount,status 1,100.00,completed 2,50.00,pendingOu référencez un fichier externe :
given: - input: ref('base__shopify__orders') format: csv fixture: order_test_dataCeci recherche tests/fixtures/order_test_data.csv dans votre projet.
Le format SQL est requis pour les modèles éphémères et les scénarios de tables vides :
given: - input: ref('base__shopify__orders') format: sql rows: | select 1 as order_id, 100.00 as amount, 'completed' as status union all select 2 as order_id, 50.00 as amount, 'pending' as statusPour les tables vides (tester les scénarios sans données) :
given: - input: ref('base__shopify__orders') format: sql rows: | select cast(null as int64) as order_id, cast(null as float64) as amount where falseConfiguration optionnelle
Au-delà des bases, vous pouvez ajouter des métadonnées, des tags et des conditions d’activation :
unit_tests: - name: test_revenue_calculation model: mrt__finance__orders description: "Valide le calcul du revenu brut incluant les taxes"
config: tags: ["critical", "finance"] meta: owner: "data-team" ticket: "DATA-1234" enabled: "{{ target.name != 'prod' }}" # v1.9+ uniquement
given: - input: ref('base__shopify__orders') rows: - {order_id: 1, subtotal: 100.00, tax_rate: 0.08} expect: rows: - {order_id: 1, gross_revenue: 108.00}Les tags permettent des exécutions de tests sélectives (dbt test --select tag:critical), tandis que la config enabled vous permet d’ignorer des tests dans certains environnements.
Considérations spécifiques à BigQuery
Comment les tests unitaires s’exécutent sur BigQuery
Quand vous exécutez un test unitaire, dbt génère une requête avec des CTEs contenant vos données mockées, puis exécute le SQL de votre modèle contre ces CTEs. La sortie est comparée ligne par ligne avec vos résultats attendus.
Cela signifie que les tests unitaires consomment des slots BigQuery (ils ne sont pas gratuits). Cependant, vous pouvez minimiser les coûts en utilisant le flag --empty lors de la construction des modèles parents :
# Construire des versions schema-only des modèles en amontdbt run --select +model_with_unit_tests --empty
# Puis exécuter les tests unitairesdbt test --select test_type:unitLe flag --empty crée des tables avec les bons schémas mais zéro ligne, ce qui est suffisant pour la compilation des tests unitaires.
Particularités connues et solutions
Les champs STRUCT doivent être complets. Contrairement aux colonnes normales où vous pouvez spécifier seulement ce dont vous avez besoin, les champs STRUCT nécessitent tous les champs imbriqués :
# Ceci ne fonctionnera pas si address a plus de champsgiven: - input: ref('base__crm__customers') rows: - {customer_id: 1, address: {city: "Paris"}} # Champs manquants !
# Spécifiez tous les champsgiven: - input: ref('base__crm__customers') rows: - {customer_id: 1, address: {street: "123 Rue Example", city: "Paris", postal_code: "75001", country: "FR"}}Les comparaisons STRUCT et ARRAY échouent avec l’égalité standard. BigQuery ne supporte pas les opérations EXCEPT sur les types complexes. Utilisez column_transformations pour les convertir en chaînes comparables :
unit_tests: - name: test_event_aggregation model: int__users_activity_summary given: - input: ref('base__ga4__events') rows: - {user_id: 1, event_type: "click", properties: {page: "/home", duration: 30}} expect: format: dict rows: - {user_id: 1, activity_details: {total_events: 1, event_types: ["click"]}} column_transformations: activity_details: "to_json_string(##column##)"Le placeholder ##column## est remplacé par la référence réelle de la colonne.
Les colonnes ARRAY au format CSV peuvent être problématiques. Préférez le format dict pour les arrays, ou utilisez le format sql pour les scénarios complexes.
Mocker les dépendances
Mocker plusieurs refs et sources
La plupart des modèles joignent plusieurs tables. Vous devez fournir des données mockées pour chaque dépendance :
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}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 vous ne joignez que sur customer_id et sélectionnez customer_segment, ces deux colonnes suffisent.
Surcharger les macros et les variables
Beaucoup de modèles utilisent des macros pour les timestamps ou la logique conditionnelle. 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'" vars: days_threshold: 30 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}C’est particulièrement important pour tester la logique basée sur le temps où current_timestamp() rendrait autrement les tests non-déterministes.
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 :
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"} # Seulement la nouvelle ligneLa Partie 2 couvre en profondeur les patterns de test incrémental.
Configuration du projet et organisation des fichiers
Où placer les tests unitaires
Colocalisez les tests unitaires avec leurs modèles en utilisant un fichier _unit_tests.yml :
models/├── base/│ ├── crm/│ │ ├── base__crm__customers.sql│ │ └── _crm__models.yml│ └── shopify/│ ├── base__shopify__orders.sql│ └── _shopify__models.yml├── intermediate/│ ├── int__customers_enriched.sql│ └── _intermediate__models.yml├── marts/│ ├── core/│ │ ├── mrt__core__customers.sql│ │ ├── _core__models.yml│ │ └── _unit_tests.yml # Tests unitaires pour les marts core│ └── finance/│ ├── mrt__finance__orders.sql│ └── _finance__models.ymltests/└── fixtures/ ├── customer_fixture.csv └── large_order_dataset.csvCela garde les tests proches du code qu’ils testent, facilitant la maintenance. Les fixtures externes vont dans tests/fixtures/ pour la réutilisabilité.
Conventions de nommage
Adoptez un pattern de nommage cohérent :
unit_tests: # Pattern: test_<model>_<scenario> - name: test_mrt_core_customers_email_validation - name: test_mrt_core_customers_null_handling - name: test_mrt_finance_orders_discount_calculation - name: test_mrt_finance_orders_zero_quantity_edge_caseDes noms descriptifs rendent les échecs de tests immédiatement compréhensibles dans les logs CI.
Exécuter les tests unitaires
Commandes CLI
# Exécuter tous les tests unitairesdbt test --select test_type:unit
# Exécuter les tests unitaires pour un modèle spécifiquedbt test --select mrt__core__customers,test_type:unit
# Exécuter les tests unitaires avec un tag spécifiquedbt test --select tag:critical,test_type:unit
# Exécuter un test unitaire spécifique par son nomdbt test --select test_mrt_core_customers_email_validation
# Construire les modèles et exécuter tous les tests (unit + data)dbt build
# Construire mais exclure les tests unitaires (pour la production)dbt build --exclude-resource-type unit_testInterpréter la sortie
Un test réussi affiche :
PASS test_mrt_core_customers_email_validationUn test échoué affiche un diff :
FAIL test_mrt_core_customers_email_validationGot: customer_id | is_valid_email 1 | falseExpected: customer_id | is_valid_email 1 | truePour un débogage détaillé, ajoutez --debug pour voir le SQL généré.
Intégration CI/CD avec GitHub Actions
Voici un workflow prêt pour la production pour BigQuery :
name: dbt CI
on: pull_request: branches: [main]
env: DBT_PROFILES_DIR: ./ GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GCP_SA_KEY_PATH }}
jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11"
- name: Install dbt run: pip install dbt-bigquery
- name: Set up GCP credentials run: echo '${{ secrets.GCP_SA_KEY }}' > /tmp/gcp-key.json env: GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
- name: Create CI dataset name run: | echo "CI_DATASET=ci_$(date +'%Y%m%d_%H%M%S')_${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Build upstream models (empty) run: | dbt run --select +test_type:unit --empty --target ci env: CI_DATASET: ${{ env.CI_DATASET }}
- name: Run unit tests run: | dbt test --select test_type:unit --target ci env: CI_DATASET: ${{ env.CI_DATASET }}
- name: Cleanup CI dataset if: always() run: | bq rm -r -f ${{ env.CI_DATASET }}Points clés :
- Un dataset unique par exécution CI évite les conflits
- Le flag
--emptyminimise les coûts BigQuery - Le nettoyage s’exécute même si les tests échouent (
if: always()) - Les tests unitaires s’exécutent séparément des tests de données
Exclure les tests unitaires de la production
Puisque les tests unitaires utilisent des données mockées, les exécuter en production n’apporte aucune valeur. Excluez-les :
# Dans votre script de déploiement productiondbt build --exclude-resource-type unit_testOu définissez une variable d’environnement :
export DBT_EXCLUDE_RESOURCE_TYPES=unit_testdbt buildRésolution des erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
| ”Not able to get columns for unit test” | Les modèles parents n’existent pas dans la base | Exécutez d’abord dbt run --select +test_type:unit --empty |
| ”node not found” pendant la compilation | Un ref() dans votre modèle n’est pas mocké dans given | Ajoutez le ref manquant comme entrée, même avec des lignes vides |
| ”check data types” mismatch | Les types de données mockées ne correspondent pas à la sortie du modèle | Utilisez un casting explicite : {amount: "10.00::numeric"} ou passez à format: sql |
| Le test d’un modèle éphémère échoue | dbt ne peut pas interroger les modèles éphémères pour le schéma | Utilisez format: sql pour les entrées de modèles éphémères (doit inclure TOUTES les colonnes) |
| La comparaison STRUCT échoue | BigQuery ne peut pas comparer les types complexes | Utilisez column_transformations avec to_json_string() |
Notes de compatibilité des versions
La syntaxe des tests unitaires a évolué à travers les versions de dbt :
- dbt 1.8 (mai 2024) : Introduction des tests unitaires. La clé
tests:a été renommée endata_tests:pour les distinguer des tests unitaires. - dbt 1.9 : Ajout de l’option de config
enabledpour l’exécution conditionnelle des tests. Nouveaux flags--resource-typeet--exclude-resource-type. - dbt 1.11 : Les tests unitaires pour les modèles désactivés sont maintenant automatiquement désactivés.
Si vous migrez depuis une version antérieure à 1.8, renommez tous les blocs tests: en data_tests: dans vos YAML pour éviter les conflits.
Et ensuite
Vous avez maintenant tout ce qu’il faut pour configurer les tests unitaires dans votre projet dbt : la syntaxe YAML, les solutions spécifiques à BigQuery, et un workflow CI/CD qui maintient les coûts bas.
La Partie 2 applique ces fondamentaux à des scénarios réels : tester les modèles incrémentaux, la logique de snapshot, les fonctions de fenêtrage, et les patterns d’analytics marketing comme la sessionisation GA4 et les modèles d’attribution.