ServicesÀ proposNotesContact Me contacter →
EN FR
Note

Tests unitaires des modèles incrémentaux dans dbt

Le pattern de test dual pour les modèles incrémentaux — surcharger is_incremental, mocker this, et comprendre que les blocs expect montrent les insertions, pas l'état final.

Planté
dbtbigquerytestingincremental processing

Les modèles incrémentaux ont deux chemins d’exécution — un pour le full-refresh, un autre pour les chargements incrémentaux normaux — et les équipes ne testent généralement qu’un seul. Le chemin du full-refresh est testé parce que c’est ce que fait dbt run pendant le développement.

Un mode de défaillance courant : le modèle fonctionne en dev et en full-refresh, mais un bug dans la clause WHERE du chemin incrémental produit des doublons qui s’accumulent sur des mois d’exécutions quotidiennes avant que quelqu’un ne le remarque.

Les tests unitaires détectent ces bugs avant le déploiement en simulant les deux chemins d’exécution avec des données de test contrôlées.

Le pattern de test dual

Chaque modèle incrémental nécessite au moins deux tests unitaires : un pour le mode full-refresh, un pour le mode incrémental. Ce n’est pas optionnel. Si vous ne testez qu’un seul mode, vous laissez la moitié de votre logique non testée.

-- models/intermediate/int__events_processed.sql
{{ config(materialized='incremental', unique_key='event_id') }}
select
event_id,
user_id,
event_type,
event_timestamp,
processed_at
from {{ ref('base__ga4__events') }}
{% if is_incremental() %}
where event_timestamp > (select max(event_timestamp) from {{ this }})
{% endif %}

Le bloc {% if is_incremental() %} est là où se cachent les bugs. Les erreurs courantes incluent les erreurs d’un entier (>= vs >), les décalages de fuseau horaire, et les références de colonnes incorrectes.

Tester le full-refresh

unit_tests:
- name: test_int_events_processed_full_refresh
model: int__events_processed
description: "En full-refresh, toutes les lignes sources devraient être traitées"
overrides:
macros:
is_incremental: false
given:
- input: ref('base__ga4__events')
rows:
- {event_id: 1, user_id: 100, event_type: "click", event_timestamp: "2024-06-01 10:00:00"}
- {event_id: 2, user_id: 100, event_type: "purchase", event_timestamp: "2024-06-02 11:00:00"}
- {event_id: 3, user_id: 101, event_type: "click", event_timestamp: "2024-06-03 12:00:00"}
expect:
rows:
- {event_id: 1, user_id: 100, event_type: "click"}
- {event_id: 2, user_id: 100, event_type: "purchase"}
- {event_id: 3, user_id: 101, event_type: "click"}

Définir is_incremental: false dans les overrides simule dbt run --full-refresh. Les trois événements apparaissent dans la sortie — aucun filtrage ne se produit.

Tester le mode incrémental

unit_tests:
- name: test_int_events_processed_incremental
model: int__events_processed
description: "En exécution incrémentale, seuls les nouveaux événements devraient être insérés"
overrides:
macros:
is_incremental: true
given:
- input: ref('base__ga4__events')
rows:
- {event_id: 1, user_id: 100, event_type: "click", event_timestamp: "2024-06-01 10:00:00"}
- {event_id: 2, user_id: 100, event_type: "purchase", event_timestamp: "2024-06-02 11:00:00"}
- {event_id: 3, user_id: 101, event_type: "click", event_timestamp: "2024-06-03 12:00:00"}
- input: this
rows:
- {event_id: 1, user_id: 100, event_type: "click", event_timestamp: "2024-06-01 10:00:00"}
expect:
rows:
- {event_id: 2, user_id: 100, event_type: "purchase"}
- {event_id: 3, user_id: 101, event_type: "click"}

Définir is_incremental: true force le mode incrémental. input: this mocke l’état actuel de la table cible — elle contient un événement avec l’horodatage 2024-06-01 10:00:00. La clause WHERE du modèle filtre les événements après cet horodatage, donc seuls les événements 2 et 3 apparaissent.

L’insight critique : expect montre les insertions, pas l’état final

Cela piège de nombreux développeurs. Le bloc expect représente ce qui est inséré ou mergé, pas l’état final de la table. Dans le test incrémental ci-dessus, event_id 1 existe déjà dans this, donc seuls les événements 2 et 3 apparaissent dans la sortie attendue. Vous testez la logique de transformation, pas l’opération de merge elle-même.

Tester la logique de merge

La configuration merge_update_columns ajoute de la complexité. Quand une ligne avec une unique_key existante arrive, BigQuery met à jour uniquement les colonnes spécifiées, laissant les autres inchangées.

{{ config(
materialized='incremental',
unique_key='user_id',
merge_update_columns=['email', 'updated_at']
) }}
unit_tests:
- name: test_int_users_current_merge_update
model: int__users_current
description: "Les utilisateurs existants devraient avoir l'email mis à jour, pas created_at"
overrides:
macros:
is_incremental: true
given:
- input: ref('base__crm__users')
rows:
- {user_id: 1, email: "nouveau@exemple.com", created_at: "2024-06-15", updated_at: "2024-06-15"}
- input: this
rows:
- {user_id: 1, email: "ancien@exemple.com", created_at: "2024-01-01", updated_at: "2024-01-01"}
expect:
rows:
- {user_id: 1, email: "nouveau@exemple.com", created_at: "2024-06-15", updated_at: "2024-06-15"}

Mise en garde importante : ce test valide que votre logique de transformation produit la bonne ligne de sortie. Il ne teste pas le comportement du merge lui-même — c’est la responsabilité de BigQuery. La sortie attendue montre created_at: "2024-06-15" car c’est ce que contiennent les données sources. En pratique, le merge réel préserverait le created_at: "2024-01-01" original depuis this. Pour les scénarios de merge plus complexes, utilisez dbt-audit-helper pour comparer les résultats réels après une exécution de test.

Tester les données tardives

Les pipelines réels reçoivent rarement des événements dans un ordre chronologique parfait. Un pattern courant utilise une fenêtre de rattrapage : au lieu de ne traiter que les événements plus récents que max(event_timestamp), vous incluez également les événements qui sont arrivés récemment (sur la base d’une colonne _loaded_at) même si leur horodatage d’événement est plus ancien.

unit_tests:
- name: test_int_events_processed_late_arriving
model: int__events_processed
description: "Les événements tardifs dans la fenêtre de rattrapage devraient être capturés"
overrides:
macros:
is_incremental: true
vars:
lookback_hours: 24
given:
- input: ref('base__ga4__events')
rows:
- {event_id: 4, event_timestamp: "2024-06-01 08:00:00", _loaded_at: "2024-06-02 10:00:00"}
- {event_id: 5, event_timestamp: "2024-06-02 09:00:00", _loaded_at: "2024-06-02 09:05:00"}
- input: this
rows:
- {event_id: 1, event_timestamp: "2024-06-01 10:00:00"}
expect:
rows:
- {event_id: 4, event_timestamp: "2024-06-01 08:00:00"}
- {event_id: 5, event_timestamp: "2024-06-02 09:00:00"}

L’événement 4 a un horodatage ancien (1er juin, 08:00) mais un temps de chargement récent (2 juin, 10:00) — il est arrivé tardivement. Un simple WHERE event_timestamp > max(event_timestamp) le manquerait. La fenêtre de rattrapage garantit sa capture.

Prérequis : le schéma doit exister

Avant d’exécuter des tests unitaires incrémentaux, construisez vos modèles incrémentaux avec :

Terminal window
dbt run --select "config.materialized:incremental" --empty

Les tests unitaires doivent inspecter la structure de la table. Ils échoueront avec “Not able to get columns for unit test” si la table cible n’existe pas. Cette commande crée des tables vides avec le bon schéma sans traiter aucune donnée.

Remarques sur le pattern dual

Les bugs de merge incrémental s’accumulent silencieusement entre les exécutions. Le pattern dual — tester à la fois is_incremental: false et is_incremental: true avec un mocking explicite de this — détecte ces bugs avant le déploiement, quand l’état incorrect est encore récupérable.