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 simple
unit_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,pending

Ou référencez un fichier externe :

given:
- input: ref('base__shopify__orders')
format: csv
fixture: order_test_data

Ceci 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 status

Pour 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 false

Configuration 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 :

Terminal window
# Construire des versions schema-only des modèles en amont
dbt run --select +model_with_unit_tests --empty
# Puis exécuter les tests unitaires
dbt test --select test_type:unit

Le 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 champs
given:
- input: ref('base__crm__customers')
rows:
- {customer_id: 1, address: {city: "Paris"}} # Champs manquants !
# Spécifiez tous les champs
given:
- 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 ligne

La 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.yml
tests/
└── fixtures/
├── customer_fixture.csv
└── large_order_dataset.csv

Cela 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_case

Des noms descriptifs rendent les échecs de tests immédiatement compréhensibles dans les logs CI.

Exécuter les tests unitaires

Commandes CLI

Terminal window
# Exécuter tous les tests unitaires
dbt test --select test_type:unit
# Exécuter les tests unitaires pour un modèle spécifique
dbt test --select mrt__core__customers,test_type:unit
# Exécuter les tests unitaires avec un tag spécifique
dbt test --select tag:critical,test_type:unit
# Exécuter un test unitaire spécifique par son nom
dbt 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_test

Interpréter la sortie

Un test réussi affiche :

PASS test_mrt_core_customers_email_validation

Un test échoué affiche un diff :

FAIL test_mrt_core_customers_email_validation
Got:
customer_id | is_valid_email
1 | false
Expected:
customer_id | is_valid_email
1 | true

Pour 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 :

.github/workflows/dbt-ci.yml
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 --empty minimise 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 :

Terminal window
# Dans votre script de déploiement production
dbt build --exclude-resource-type unit_test

Ou définissez une variable d’environnement :

Terminal window
export DBT_EXCLUDE_RESOURCE_TYPES=unit_test
dbt build

Résolution des erreurs courantes

ErreurCauseSolution
”Not able to get columns for unit test”Les modèles parents n’existent pas dans la baseExécutez d’abord dbt run --select +test_type:unit --empty
”node not found” pendant la compilationUn ref() dans votre modèle n’est pas mocké dans givenAjoutez le ref manquant comme entrée, même avec des lignes vides
”check data types” mismatchLes types de données mockées ne correspondent pas à la sortie du modèleUtilisez un casting explicite : {amount: "10.00::numeric"} ou passez à format: sql
Le test d’un modèle éphémère échouedbt ne peut pas interroger les modèles éphémères pour le schémaUtilisez format: sql pour les entrées de modèles éphémères (doit inclure TOUTES les colonnes)
La comparaison STRUCT échoueBigQuery ne peut pas comparer les types complexesUtilisez 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 en data_tests: pour les distinguer des tests unitaires.
  • dbt 1.9 : Ajout de l’option de config enabled pour l’exécution conditionnelle des tests. Nouveaux flags --resource-type et --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.