TDD avec dbt

J’aime beaucoup dbt et j’en parle (trop) souvent. Si vous êtes déjà venu·e sur ce blog, vous connaissez forcément un minimum vous aussi.

dbt est devenu un pilier essentiel dans l'arsenal des data ingénieurs/data analystes : ce nouveau pokémon appelé Analytics Engineer. Le problème (et aussi ce qui est génial) c’est que dbt est très permissif. Sans bonnes pratiques mises en place dès le début de notre projet, on peut vite faire n’importe quoi. Bien que le craft soit le plus souvent associé au monde du développement “classique”, les bonnes pratiques aux acronymes farfelus (DRY, KISS, YAGNI, TDD, …) peuvent aussi être mises en place grâce à dbt !

Dans un précédent article [1], je vous ai présenté comment mettre en place des tests de qualité de données (que j’avais appelés unitaires par un raccourci fâcheux) et des tests d’intégration (ou de non régression). Ces derniers sont très importants pour s’assurer qu’une modification sur un modèle n’impacte pas ses “enfants”. Mais il y a plusieurs limitations :

  • il est difficile de tester toutes les nuances d’un modèle, cela devient vite compliqué à maintenir (une ligne dans la source impacte tous les modèles qui s’en servent),
  • il est difficile de tester comment se comportent les modèles incrémentaux (besoin d’avoir plusieurs .csv pour les expects par exemple),
  • on ne peut pas tester un modèle éphémère.

Mais la version bêta de dbt 1.8 [2] annonce quelque chose de génial : dbt va supporter nativement les tests unitaires de modèles !

Dans cet article, nous allons découvrir comment mettre en place ces tests à travers un exemple pratique en utilisant le TDD (Test Driven Development).

Présentation et utilisation des tests unitaires dans dbt 1.8

Jusqu’à présent, dbt permettait de faire des tests de qualité de données. Ces derniers sont maintenant renommés en “data_tests” afin de ne pas les confondre avec les “unit_tests” que nous voyons aujourd’hui. Dans la version 1.8, dbt se concentre sur le support natif des tests unitaires de modèles SQL [4]. Un modèle représente une unité atomique de travail dans le pipeline de transformation de données et il est important de tester son comportement de manière isolée pour garantir son bon fonctionnement.

Le support natif va permettre de facilement tester chaque petite nuance de nos modèles en mettant des tests spécifiques sur chacune d’elles. Il va aussi simplifier l’écriture et l’exécution de ces tests. Attention, les tests unitaires vérifient le comportement de la requête DQL (Data Query Language, c'est-à-dire le SELECT) du modèle compilé par dbt. Cependant ils ne valident pas la matérialisation i.e. que la table soit véritablement créée ou, pour les modèles incrémentaux, que les lignes soient insérées dans la table finale.

Comme les data tests et la description des modèles, les tests unitaires s’écrivent dans des fichiers _.yml _qui se trouvent dans le dossier de vos modèles. Prenons un exemple concret d’un modèle qui filtre les utilisateurs ayant une adresse mail Ippon :

with users as (
    select * from {{ ref('dim_users') }}
)

, filtered as (
    select
        user_id
    from
        users
    where
        substring(email from '@(.*?)\.') = 'ippon'
)

select * from filtered
Extrait de code 1 : models/ippon_users.sql

Le strict minimum pour mettre en place un test unitaire sur ce modèle serait :

unit_tests:
  - name: test_filter_user
    description: Keeps ippon users and filter others
    model: ippon_users
    given:
      - input: ref('dim_users')
        rows:
          - { user_id: '1', email: 'user@ippon.fr'}
          - { user_id: '2', email: 'user@gmail.com'}
    expect:
      rows:
        - { user_id: '1' }
    Extrait de code 2 : models/ippon_users.yml

    • 1ère ligne : on voit que la manière de déclarer ces tests est différente des data tests. En effet, les tests unitaires sont des ressources à part entière (on les déclare en utilisant le mot clef unit_tests comme première clef) alors qu’à l’inverse, les data tests sont des propriétés appliquées à un modèle ou une colonne,
    • lignes 2 à 4 : on donne un nom au test (on peut mettre une infinité de tests par modèle), une description et le modèle sur lequel le test est appliqué,
    • lignes 5 à 9 : le bloc given correspond à la phase de mise en place de l'environnement initial nécessaire pour exécuter le test. On va mocker (imiter) les entrées de chaque modèle utilisé. On peut ne renseigner que le strict minimum pour les besoins du test,
    • lignes 10 à 12 : le bloc expect correspond à la définition du résultat prévu, permettant ainsi la vérification du comportement attendu du code ou du modèle testé.

    On pourrait aussi ajouter un bloc overrides qui permettrait de surcharger la sortie d’une ou plusieurs macros ou variable, ce qui permettra notamment de mimer/tester le comportement du modèle en mode incrémental. On le verra plus tard dans l'article. Enfin, lorsque l’on travaille avec des modèles versionnés [5], on peut spécifier sur quelles versions du modèle le test doit s’effectuer, par défaut toutes [6].

    Les lignes d’input ou d’expect s’appellent aussi des fixtures [7].) et peuvent être définies de plusieurs manières possibles [8]. Sous forme de dictionnaire de lignes comme dans l’extrait de code 1, sous forme de csv en chaîne de texte ou directement dans un fichier .csv séparé dans le dossier tests/fixtures. Nous verrons ces différentes méthodes dans la partie suivante.

    Dans la section suivante, on explorera en détail la manière d'écrire et de mettre en œuvre des tests unitaires dans dbt 1.8, en fournissant un guide pas à pas pour commencer à utiliser cette fonctionnalité de manière efficace et judicieuse.

    Exemples pratiques en mode TDD

    Le Test Driven Development (TDD ou développement piloté par les tests) est une approche de développement logiciel où les tests sont écrits avant même que le code ne soit implémenté. Le cycle de développement suit généralement trois étapes : rédaction d'un test unitaire qui échoue, implémentation du code nécessaire pour réussir le test, puis refactoring du code pour maintenir la qualité. Cette pratique favorise une conception modulaire et une meilleure maintenabilité du code, permise notamment par une bonne couverture de test.

    Le TDD est considéré comme une bonne pratique car il encourage une approche itérative et incrémentale du développement logiciel. En écrivant les tests en premier, les développeurs sont contraints de réfléchir à la conception de leur code et aux fonctionnalités attendues avant même de commencer à écrire du code de production. Cela conduit à une meilleure qualité du code, une réduction des bugs et une plus grande confiance dans la stabilité du logiciel. De plus, le processus itératif du TDD permet une évolution progressive du code, facilitant ainsi la maintenance et l'ajout de nouvelles fonctionnalités.

    Dans cette section, nous illustrerons l'utilisation des tests unitaires dans dbt 1.8 en suivant une approche TDD pour le développement d’un modèle de transformation agg_hourly_domain_emails. Le code utilisé est disponible sur ce repo github : tdd-dbt-demo, je vous invite à cloner le repository afin d’essayer de faire les modèles qui feront passer les tests unitaires que je donnerai dans l’article (suivre le README pour l’installation). Vous trouverez les solutions dans les branches correspondantes.

    Exemple 1

    Dans un premier temps, l’équipe de recrutement a besoin d’informations sur les éméteurs et l’heure d’envoie des emails. Pour y répondre, on se base sur une table (mimée dans un fichier fact_emails.csv que l’on utilisera comme seed) et on veut compter le nombre d'e-mails reçus par heure et par domaine [9] (branche exercice_1). Convaincu par nos amis crafteux, on décide donc d’écrire les tests de l’Extrait de code 3, d’abord et d’itérer jusqu’à ce qu’ils passent.

    unit_tests:
        - name: test_group_by_hour
          description: It groups by hour
          model: agg_hourly_domain_emails
          given:
            - input: ref('fact_emails')
              rows:
                - { received_at: '2021-01-01 00:00:00' }
                - { received_at: '2021-01-01 00:30:00' }
                - { received_at: '2021-01-01 01:00:00' }
                - { received_at: '2021-01-02 00:00:00' }
          expect:
            rows:
              - { sent_at_hour: '2021-01-01 00:00:00', email_count: 2 }
              - { sent_at_hour: '2021-01-01 01:00:00', email_count: 1 }
              - { sent_at_hour: '2021-01-02 00:00:00', email_count: 1 }
    
        - name: test_group_by_domain
          description: It groups by domain
          model: agg_hourly_domain_emails
          given:
            - input: ref('fact_emails')
              rows:
                - { sender_address: 'toto@ippon.fr' }
                - { sender_address: 'titi@ippon.fr' }
                - { sender_address: 'tata@google.com' }
          expect:
            rows:
              - { domain: 'ippon.fr', email_count: 2 }
              - { domain: 'google.com', email_count: 1 }
    
        - name: test_group_by_domain_hour
          description: It groupss by domain and hour
          model: agg_hourly_domain_emails
          given:
            - input: ref('fact_emails')
              rows:
                - { sender_address: 'toto@ippon.fr', received_at: '2021-01-01 00:00:00' }
                - { sender_address: 'titi@ippon.fr', received_at: '2021-01-01 00:30:00' }
                - { sender_address: 'tata@google.com', received_at: '2021-01-01 00:00:00' }
                - { sender_address: 'tata@google.com', received_at: '2021-01-01 01:00:00' }
          expect:
            rows:
              - { domain: 'ippon.fr', sent_at_hour: '2021-01-01 00:00:00', email_count: 2 }
              - { domain: 'google.com', sent_at_hour: '2021-01-01 00:00:00', email_count: 1 }
              - { domain: 'google.com', sent_at_hour: '2021-01-01 01:00:00', email_count: 1 }
    Extrait de code 3 : models/agg_hourly_domain_emails.yml

    Vous pouvez voir le code compilé du test test_group_by_domain_hour en Annexe 2 (ou plus lisible avec les autres, sur le repo GitHub). A l’instar du code compilé des modèles éphémères référencés [10], dbt va créer une CTE (Common Table Expression) [11] pour chaque input fixture qu’il va utiliser là où ils sont référencés dans le code. On peut aussi noter deux choses importantes :

    • dbt va remplacer les champs de la table référencée par des null si on ne les a pas précisés
    • dbt va typer chaque colonne de la table référencée par le type qu’il aura trouvé dans la table matérialisée dans la base de données

    Ces deux points amènent une limitation sur les tests unitaires : on doit matérialiser tous les modèles qui sont référencés dans un modèle que l’on veut tester. Nous verrons dans la prochaine partie un moyen détourné pour les faire fonctionner facilement, même avec des modèles éphémères.

    Exemple 2

    L’équipe Data Platform trouve que, bien que très simple, votre modèle semble faire beaucoup de calculs inutiles. En effet, en l’état, votre modèle fonctionne mais il tourne tous les jours et recalcule l’entièreté des données : nous allons modifier notre modèle pour qu’il soit incrémental et ne calculer que ce qui est nécessaire. (On part du principe que les emails arrivent de manière chronologique et heure par heure afin de simplifier les choses).

    On va donc écrire le test de l’Extrait de code 4 dont le seul but est de tester l’incrémentalité (se mettre sur la branche _exercice_1 _du repo pour avoir la réponse de la partie précédente). Pour plus de lisibilité, on n’ajoute que les colonnes qui servent pour le test en cours. Enfin, pour mimer l’incrémentalité, on va surcharger la sortie de la macro is_incremental. (A partir du moment où on l’utilise dans le code de notre modèle, il faudra aussi le surcharger sur le test précédent en mettant false.)

    unit_tests:
        - name: test_incremental_group_by_domain_hour
          description: It computes only new data when is_incremental is true
          model: agg_hourly_domain_emails
          overrides:
            macros:
              is_incremental: true
          given:
            - input: ref('fact_emails')
              format: csv
              fixture: fixture_fact_emails
            - input: this
              format: csv
              rows: |
                sent_at_hour,email_count
                '2021-01-01 00:00:00',1
          expect:
            rows:
              - { sent_at_hour: '2021-01-02 00:00:00', email_count: 1 }
    Extrait de code 4 : models/agg_hourly_domain_emails.yml
    received_at
    '2021-01-01 00:00:00'
    '2021-01-02 00:00:00'
    
    Extrait de code 5 : tests/fixtures/fixture_fact_emails.csv

    Vous pouvez voir le code compilé de ce test en Annexe 3 (ou plus lisible, sur le repo GitHub). On voit bien qu’à la compilation, on rentre dans la condition d’incrémentalité ce qui permet de tester cette fonctionnalité. Pour l’instant, dbt ne nous permet pas de tester les conditions de merge dans la table finale mais ils y travaillent.

    De plus, l’Extrait de code 4 montre trois choses :

    • On peut surcharger la sortie des macros utilisées dans notre code
    • On peut être très minimaliste sur les colonnes que l’on renseigne afin de tester une partie très précise de notre modèle
    • On peut mettre en place des fixtures de différentes manières. On peut même utiliser directement du SQL (lorsque cette PR sera release : Support using SQL in unit testing fixtures) ce qui permet d’utiliser des types plus complexes facilement.

    Bonnes pratiques et conseils

    Nous avons vu comment mettre en place des tests unitaires en utilisant une méthode pilotée par les tests. Dans cette section, nous allons voir quelques conseils pour bien tester vos modèles de façon efficace et lisible.

    1. De manière générale, je recommande d’avoir un fichier .yml par modèle matérialisé dans votre base de donnée (c’est le minimum, pour ceux qui ne sont pas matérialisés, je recommande au moins d’avoir la description de ce que fait le modèle). Cela permet de mettre la documentation et les tests unitaires au même endroit. De plus, il est plus facile de mettre à jour ce fichier s’il se trouve juste à côté du modèle et qu’on a pas à fouiller dans un long fichier avec tous les modèles
    2. dbt recommande de ne tester que les modèles qui ont un fonctionnement complexe. Je rajouterai qu’il faut tester chaque spécificité de manière séparée plutôt que d’avoir un gros test illisible, quitte à avoir plusieurs tests sur un même modèle
    3. Afin d’éviter le bruit dans les tests, ne renseigner que les colonnes utiles pour le test en cours
    4. Éviter le plus possible les fixtures dans des fichiers .csv. Cela permet la réutilisation au détriment de la lisibilité car pour comprendre le test, il faut regarder à plusieurs endroits. Donc privilégiez les descriptions en ligne dans le même .yml.
    5. Pour tous vos modèles utilisant des opérations sur des dates, utiliser le plus possible les macros misent à disposition par dbt [12] ou utiliser le package dbt-date. Cela permettra de rendre votre code plus agnostique et aussi de pouvoir surcharger leur sortie afin de tester vos modèles facilement
    6. Maintenant qu’il y a des “data_tests” et des “unit_tests”, il faut faire attention à bien tout tester lors de la CICD mais lors des tests réguliers de qualité de données, il faut éviter de faire tourner les tests unitaires inutilement avec la commande suivante
    7. Comme dit précédemment, dbt utilise les schémas des modèles matérialisés pour inférer les types des colonnes et permettre de ne préciser qu’un sous ensemble de colonnes, ce qui rend, a priori, impossible de tester des modèles s’appuyant sur des modèles éphémères ou plus globalement, de tester nos modèles sans matérialiser l’ensemble de notre pipeline. Pour pallier à ceci, il y a deux solutions de contournement :
    • A partir de dbt 1.8, on peut ajouter le flag –empty afin de faire tourner notre pipeline en limitant toutes les sources et les refs à 0 ligne ! On va créer tous nos modèles en créant des tables vides, ce qui permet ensuite de tester nos modèles facilement
    • Un peu plus compliqué mais, lors de la phase de CI, on peut modifier la matérialisation de nos modèles éphémères afin de pouvoir les tester ou tester les modèles qui les référencent comme dans l’Extrait de code 6.
    {{
        config(materialized = var('ci_materialization', 'ephemeral'))
    }}
    
    select 1 as toto
    
    Extrait de code 6: exemple d’un modèle éphémère avec une matérialisation variable

    On peut ensuite imaginer créer les modèles lors de la CI avant de tout tester en lançant la commande :

    dbt run --vars 'ci_materialization: table' --empty

    J’espère qu’avec ces conseils, vous allez pouvoir tester vos modèles dbt de façon simple, efficace et fiable pour garantir la qualité et la robustesse de vos analyses et prévenir des mises en production hâtives.

    Conclusion

    Dans cet article, nous avons exploré la fonctionnalité des tests unitaires dans dbt 1.8 et leur intégration dans le processus de développement de modèles de données. En suivant une approche TDD, nous avons illustré comment ces tests assurent la fiabilité et la robustesse des modèles en ajoutant les bonnes pratiques venant du développement logiciel.

    Maintenant que l’on a vu comment tester unitairement nos modèles… Il ne vous reste plus qu’à mettre des tests sur vos macros ! dbt a pour le moment sorti du scope de la version 1.8 le support natif de ces tests (ce qui ne doit pas être simple, je ne leur en veux pas du tout !) donc en attendant, je vous conseille de lire le super article de Nicolas Hong et notamment la partie sur les tests unitaires de macros : dbt : Vous devez tester vos macros

    Annexes

    with emails as (
        select * from {{ ref('fact_emails') }}
    )
    
    , count_emails as (
        select
            date_trunc('hour', received_at) as sent_at_hour
            , substring(sender_address from '@(.*)') as domain
            , count(*) as email_count
        from
            emails
        group by
            1, 2
    )
    
    select * from count_emails
    Annexe 1 : models/agg_hourly_domain_emails.sql (exercice_1)
    with  __dbt__cte__fact_emails as (
    
    -- Fixture for fact_emails
    select cast(null as integer) as email_id, cast(null as text) as object, 
        
        cast('toto@ippon.fr' as text)
     as sender_address, 
        
        cast('2021-01-01 00:00:00' as timestamp without time zone)
     as received_at
    union all
    select cast(null as integer) as email_id, cast(null as text) as object, 
        
        cast('titi@ippon.fr' as text)
     as sender_address, 
        
        cast('2021-01-01 00:30:00' as timestamp without time zone)
     as received_at
    union all
    select cast(null as integer) as email_id, cast(null as text) as object, 
        
        cast('tata@google.com' as text)
     as sender_address, 
        
        cast('2021-01-01 00:00:00' as timestamp without time zone)
     as received_at
    union all
    select cast(null as integer) as email_id, cast(null as text) as object, 
        
        cast('tata@google.com' as text)
     as sender_address, 
        
        cast('2021-01-01 01:00:00' as timestamp without time zone)
     as received_at
    ), emails as (
        select * from __dbt__cte__fact_emails
    )
    
    , count_emails as (
        select
            date_trunc('hour', received_at) as sent_at_hour
            , substring(sender_address from '@(.*)') as domain
            , count(*) as email_count
        from
            emails
        group by
            1, 2
    )
    
    select * from count_emails
    
    Annexe 2 : test_group_by_domain_hour compilé (exercice_1)
    with  __dbt__cte__agg_hourly_domain_emails as (
    
    -- Fixture for agg_hourly_domain_emails
    select 
        
        cast('''2021-01-01 00:00:00''' as timestamp without time zone)
     as sent_at_hour, cast(null as text) as domain, 
        
        cast('1' as bigint)
     as email_count
    ),  __dbt__cte__fact_emails as (
    
    -- Fixture for fact_emails
    select cast(null as integer) as email_id, cast(null as text) as object, cast(null as text) as sender_address, 
        
        cast('''2021-01-01 00:00:00''' as timestamp without time zone)
     as received_at
    union all
    select cast(null as integer) as email_id, cast(null as text) as object, cast(null as text) as sender_address, 
        
        cast('''2021-01-02 00:00:00''' as timestamp without time zone)
     as received_at
    ), emails as (
        select * from __dbt__cte__fact_emails
        
            where
                date_trunc('hour', received_at) > (select max(sent_at_hour) from __dbt__cte__agg_hourly_domain_emails)
        
    )
    
    , count_emails as (
        select
            date_trunc('hour', received_at) as sent_at_hour
            , substring(sender_address from '@(.*)') as domain
            , count(*) as email_count
        from
            emails
        group by
            1, 2
    )
    
    select * from count_emails
    Annexe 3 : test_incremental_group_by_domain_hour compilé (exercice_2)

    Références

    [1] - Upgrading to v1.8 (beta)

    [2] - Testez votre code SQL avec dbt

    [3] - dbt Slack thread

    [4] - Unit tests

    [5] - Model versions

    [6] - Unit testing versioned SQL models

    [7] - About fixtures

    [8] - Supported data formats for unit tests

    [9] - Principe de base du nom de domaine

    [10] - How do EPHEMERAL dbt models work?

    [11] - What Is a Common Table Expression (CTE) in SQL?

    [12] - Date and times functions