dbt : Comment tester ses macros

dbt, c’est cool ! Et comme ce n’est pas la première fois qu’on vous en parle sur ce blog, vous devriez maintenant comprendre l’engouement que l’on porte pour cet incroyable outil.

Jinja est l’une des forces de dbt. Ce langage de templating nous permet de faire tout un tas de choses qui seraient autrement impossibles avec du SQL classique. Grâce à lui, on va notamment pouvoir factoriser notre code et ainsi appliquer les préceptes DRY (Don’t Repeat Yourself).

Mais… Il y a un “mais”. Jinja est un langage très verbeux. C’est complexe à écrire, à lire et donc à maintenir. Par conséquent, l’écriture de tests pour nos macros est vitale afin de s’assurer du bon fonctionnement et de la non-régression de notre code.

Après avoir lu cet article, vous serez en mesure de mettre en place des tests pour vos macros tout en surmontant l’affreux désagrément de Jinja : les espaces… (oui, vous avez bien lu 😅). Et pour bien comprendre le problème, nous devons dans un premier temps revoir ensemble la syntaxe du langage.

Allez, pas une minute à perdre, c’est parti !

La syntaxe Jinja

Le Jinja se base sur deux types de balise :

  • {{ ... }} : les balises expressions permettent d’afficher quelque chose. Que ça soit le contenu d’une variable, le résultat d’une expression ou le retour d’une macro,
  • {% ... %} : les balises statements nous permettent de faire tout un tas de chose comme :

Déclarer des variables

Cela se fait grâce à la balise {% set %}. Il existe deux manières de déclarer une variable :

  • en mode inline avec une balise “auto-fermante” :

    {# Voici une balise commentaire #}
    {% set str_val = 'value' %} {# str_val est de type string #}
    {% set int_val = 1 %} {# int_val est de type integer #}
    {% set bool_val = False %} {# bool_val est de type boolean #}
    {% set list_val = [True, 2, "Trois"] %} {# list_val est de type list #}
    {% set dict_val = {'key': 'value'} %} {# dict_val est de type dict #}
    {% set expr_val = 1 + 1 %} {# expr_val stocke le résultat de l'expression, ici : 2 #}
    {% set rtrn_val = my_macro() %} {# rtrn_val stocke la valeur retourné par my_macro #}
    
  • en mode bloc, où la valeur de la variable sera comprise entre une balise ouvrante et une balise fermante :

    {# Contrairement au mode inline, le mode bloc permet seulement de déclarer des chaînes de caractères #}
    
    {% set str_val %}
        value n° {{ 1 + 1 }} {# Les balises Jinja sont interprétées dans le bloc #}
    {% endset %}
    

Dans l’exemple précédent, d’après vous, quelle va être la valeur de la variable str_val ? La réponse est un peu dure à visualiser, alors je vous donne l’équivalent de la déclaration en Python :

str_val = '\n\tvalue n° 2 \n'

Vous devriez commencer à entrevoir le problème que vont poser les espaces, retours à la ligne et tabulations pour l’écriture de tests.


⚠️ Pour des questions de simplicité, je vais maintenant templatiser cet article :

{% set espaces = 'caractères espace, retour à la ligne et tabulation' %}

Cela veut dire qu’à chaque fois que vous voyez la balise expression {{espaces}}, vous devrez lire : “caractères espace, retour à la ligne et tabulation”.


Revenons au problème des {{espaces}} indésirables. Il y a plusieurs manières de les gérer :

  1. Utiliser exclusivement le mode de déclaration inline. Malheureusement, pour des raisons de lisibilité, cette solution n’est pas idéale pour de longues chaînes de caractères.

  2. Ne pas faire de saut de ligne après la balise ouvrante et avant la balise fermante :

    {% set str_val %}select
        'test' as col_name{% endset %}
    
    {# Ici str_val vaut : "select\n\t'test' as col_name". Les {{espaces}} intermédiaires sont conservés #}
    

    Même constat, on perd en lisibilité.

  3. Utiliser la syntaxe spéciale de Jinja qui permet de gérer les {{espaces}} générés par les balises. On en parle un peu plus loin 😉.

If/elif/else

Grâce à Jinja, on va pouvoir exécuter des instructions soumises à conditions.

{% if condition1 %}
	{# Instructions dans le cas où la première condition est remplie #}
{% elif condition2 %}
	{# Instructions dans le cas où (not condition1 and condition2) #}
{% else %}
	{# Instructions dans le cas où aucune des conditions précédentes n'est remplie #}
{% endif %}

Bien évidemment, les balises {% elif … %} et {% else %} sont facultatives. Et vous pouvez utiliser autant de balise {% elif … %} que vous le souhaitez.

J’en profite pour préciser que, en Jinja, les indentations sont purement cosmétiques. Elles aident seulement à la lisibilité du code.

Boucles

En ce qui concerne les boucles, Jinja permet seulement de faire des boucles for :

{% for i in sequence %}
	{# Instructions à effectuer dans la boucle #}
{% endfor %}

Dans le contexte de la boucle, Jinja met à disposition la variable loop qui contient des attributs utiles concernant l’itération en cours :

{% for i in ['col_a', 'col_b'] %}
	{{ loop.index }} {# Contient l'index de l'itération #}
	{{ loop.first }} {# True pour la première itération #}
	{{ loop.last }} {# True pour la dernière itération #}
	{{ loop.previtem }} {# Contient l'élément de l'itération précédente #}
	{{ loop.nextitem }} {# Contient l'élément de l'itération suivante #}
{% endfor %}

Attention, toutes les variables définies dans le contexte d'une boucle ne sont accessibles que dans ce contexte.

{% set var = 1 %}
{{ var }} {# var vaut 1 #}
{% for i in [2] %}
	{{ var }} {# var vaut 1 #}
	{% set var = i %}
	{{ var }} {# var vaut 2 #}
{% endfor %}
{{ var }} {# en dehors de la boucle, var vaut 1 #}

Si jamais vous voulez définir ou mettre à jour une variable dans une boucle, vous pouvez utiliser un namespace :

{% set ns = namespace() %}
{% set ns.var = 1 %}
{{ ns.var }} {# ns.var vaut 1 #}
{% for i in [2] %}
	{{ ns.var }} {# ns.var vaut 1 #}
	{% set ns.var = i %}
	{{ ns.var }} {# ns.var vaut 2 #}
{% endfor %}
{{ ns.var }} {# ns.var vaut 2 #}

Enfin, vous pouvez ajouter une balise else à votre boucle. Cela va vous permettre d’exécuter des instructions dans le cas où la séquence serait vide et qu’il n’y aurait pas d’itération.

{% for i in [] %} {# La séquence sur laquelle itérer est vide #}
	{# Vu qu'il n'y a pas d'itérations, les instructions ici ne seront pas exécutées #}
{% else %}
	{# Les itérations ici seront exécutées #}
{% endfor %}

Macros

Les macros sont des fonctions que vous pouvez appeler sur l’ensemble de votre projet. dbt est packagé avec pleins de macros qui sont documentées ici. En voici quelques exemples :

  • source : permet de référencer une source en renvoyant le nom pleinement qualifié de la ressource.
  • ref : permet de référencer un model, un seed, un snapshot…
  • run_query : permet d’exécuter une requête SQL et retourne son résultat.

Bien entendu, vous pouvez déclarer vos propres macros. La plupart du temps, elles nous permettent de générer et retourner des requêtes ou des portions de requête SQL à utiliser dans nos modèles. Mais vous pouvez faire toutes sortes d’opérations.

{% macro my_macro(arg1, arg2) %}
	{# Instructions de la macro #}
{% endmacro %}

Il y a plusieurs moyens pour faire en sorte que notre macro retourne une valeur. La première est d’utiliser la macro return.

{% macro cast_columns_explicit_return(source_table, cast_dict) %}
{% set casted_columns = [] %}
	{% for type, columns in cast_dict.items() %}
    		{% for column_name in columns %}
        		{% set column_expr = 'cast({0} as {1}) as {0}'.format(column_name, type) %}
        		{% do casted_columns.append(column_expr) %} {# la balise do sert à executer des instructions sans s'occuper des retours #}
    		{% endfor %}
	{% endfor %}
	{% set casted_columns = ', '.join(casted_columns) %}
	{% set sql = 'select {} from {}'.format(casted_columns, source_table) %}
	{{ return(sql) }}
{% endmacro %}

L’alternative est de faire un retour implicite. Dans ce cas, la macro va retourner tout ce qui est affiché entre sa balise de début et de fin.

{% macro cast_columns_implicit_return(source_table, cast_dict) %}
select
	{% for type, columns in cast_dict.items() %}
    	{% set outer_loop_is_first = loop.first %}
        {% for col_name in columns %}
        	{% if not(outer_loop_is_first and loop.first) %}
            	,
            {% endif %}
            cast({{ col_name }} as {{ type }}) as {{ col_name }}
        {% endfor %}
    {% endfor %}
from {{ source_table }}
{% endmacro %}

Malgré une syntaxe différente, ces deux macros retournent des requêtes identiques d’un point de vue SQL. Cependant, la chaîne de caractère va être différente à cause… vous l’avez deviné : des {{espaces}}.

En appelant la première macro comme suit :

{{ cast_columns_explicit_return('table_src', {'int': ['int_col_a', 'int_col_b'], 'boolean': ['bool_col_a']}) }}

on obtient :

select cast(int_col_a as int) as int_col_a, cast(int_col_b as int) as int_col_b, cast(bool_col_a as boolean) as bool_col_a from table_src

Ici, pas de surprise. La chaîne de caractères est bien comme on l’a construite.

En revanche, en appelant la macro utilisant un retour implicite avec les mêmes arguments :

{{ cast_columns_implicit_return('table_src', {'int': ['int_col_a', 'int_col_b'], 'boolean': ['bool_col_a']}) }}

voilà ce qu’on obtient :


	select
   	 
       	 
       	 
           	 
            	cast(int_col_a as int) as int_col_a
       	 
           	 
                	,
           	 
            	cast(int_col_b as int) as int_col_b
       	 
   	 
       	 
       	 
           	 
                	,
           	 
            	cast(bool_col_a as boolean) as bool_col_a
       	 
   	 
	from table_src

Je sais ce que vous vous dites :

Tout est chaos !!! Comment vais-je bien pouvoir tester mes macros ? Il faut proscrire le retour implicite.

Je ne suis pas tout à fait d’accord. Le retour implicite est souvent plus lisible que son équivalent explicite.

De plus, il y a une explication logique à tout ce désordre. En effet, ces {{espaces}} non-désirés correspondent en fait aux balises statements utilisées dans notre script. Mais si le caractère prédictif du comportement de Jinja est plutôt rassurant, la perspective de devoir prévoir ces {{espaces}} lors de l’écriture de nos tests l’est beaucoup moins.

Gérer les espaces générés par les balises

En utilisant des tirets au début et/ou à la fin des balises Jinja, nous allons pouvoir supprimer les {{espaces}} générés par les balises. Puisqu’un exemple vaut mieux que mille mots, voici une petite démonstration.

Partons de l’exemple ci-dessous :

Jinja Compilé
Avant la balise de début
{% if True %}
    Entre les balises
{% endif %}
Après la balise de fin
Avant la balise de début

    Entre les balises

Après la balise de fin

On peut :

  • supprimer les {{espaces}} avant la balise de début :
Jinja Équivalent à Compilé
Avant la balise de début
{%- if True %}
    Entre les balises
{% endif %}
Après la balise de fin
Avant la balise de début{% if True %}
    Entre les balises
{% endif %}
Après la balise de fin
Avant la balise de début
    Entre les balises

Après la balise de fin

  • supprimer les {{espaces}} après la balise de début :
Jinja Équivalent à Compilé
Avant la balise de début
{% if True -%}
    Entre les balises
{% endif %}
Après la balise de fin
Avant la balise de début
{% if True %}Entre les balises
{% endif %}
Après la balise de fin
Avant la balise de début
Entre les balises

Après la balise de fin

  • supprimer les {{espaces}} avant la balise de fin :
Jinja Équivalent à Compilé
Avant la balise de début
{% if True %}
    Entre les balises
{%- endif %}
Après la balise de fin
Avant la balise de début
{% if True %}
    Entre les balises{% endif %}
Après la balise de fin
Avant la balise de début

    Entre les balises
Après la balise de fin

  • supprimer les {{espaces}} après la balise de fin :
Jinja Équivalent à Compilé
Avant la balise de début
{% if True %}
    Entre les balises
{% endif -%}
Après la balise de fin
Avant la balise de début
{% if True %}
    Entre les balises
{% endif %}Après la balise de fin
Avant la balise de début

    Entre les balises
Après la balise de fin

On peut bien évidemment cumuler les tirets :

Jinja Compilé
Avant la balise de début
{% if True -%}
    Entre les balises
{%- endif %}
Après la balise de fin
Avant la balise de début
Entre les balises
Après la balise de fin
Ça a l’air top tout ça ! On va pouvoir régler nos problèmes d'{{espaces}} et tester nos macros facilement !

Encore une fois, je vais devoir vous contredire. Voilà mon point de vue.

D’apparence simple à utiliser, cette syntaxe est dure à maîtriser. En l’utilisant, il est très facile d’introduire des erreurs en supprimant par mégarde des {{espaces}} nécessaires à notre requête SQL.

Jinja Compilé
Avant la balise de début
{%- if True -%}
    Entre les balises
{%- endif %}
Après la balise de fin
Avant la balise de débutEntre les balises
Après la balise de fin

De plus, cette syntaxe ne permet de traiter que les {{espaces}} se trouvant directement avant/après une balise. Dans le cas où la chaîne de caractère contenue entre deux balises est composée de plusieurs lignes, les {{espaces}} intermédiaires seront conservés.

Jinja Compilé
Avant la balise de début
{% for i in range(0, 4, 2) -%}
    i vaut {{ i }}
    i vaut {{ i + 1}}
{% endfor -%}
Après la balise de fin
Avant la balise de début
i vaut 0
    i vaut 1
i vaut 2
    i vaut 3
Après la balise de fin

Enfin, utiliser cette syntaxe durant le développement nous force à nous focaliser sur le comportement de Jinja plutôt que sur la logique à implémenter. Bien qu’elle permette de rendre le code compilé plus lisible, je vous conseille de vous concentrer sur le fond plutôt que sur la forme et d’oublier cette histoire d’{{espaces}}. Une fois que la macro aura passé les tests et que vous vous serez assurés de son bon fonctionnement, vous pourrez toujours repasser sur votre code pour y ajouter des tirets dans vos balises.

Mais alors, comment allons-nous faire pour tester facilement nos macros retournant des chaînes de caractères ?

Ayez foi ! Nous allons voir cela ensemble dans la deuxième partie de cet article.

Les tests unitaires de macro

Les pré-requis

Avant toute chose, je vous conseille de créer un nouveau projet dbt. Cela va permettre d’isoler les ressources nécessaires aux tests de celles de votre projet. En plus de nos tests unitaires de macros, on peut aussi y inclure les seeds et tests utilisés pour réaliser les tests d’intégration dont parle Jeremy Nadal dans son super article.

La convention veut qu’on nomme ce projet interne integration_tests. Voilà à quoi devrait ressembler la hiérarchie de votre projet :

your_dbt_project/
├── analyses/
├── integration_tests/  <- le projet dédié aux tests
│   ├── analyses/
│   ├── macros/
│   ├── models/
│   ├── seeds/
│   ├── snapshots/
│   ├── tests/
│   ├── dbt_project.yml
│   └── packages.yml
├── macros/
├── models/
├── seeds/
├── snapshots/
├── tests/
├── dbt_project.yml
└── packages.yml

Il faut ensuite déclarer votre projet principal en tant que dépendance du projet integration_tests. Cela va vous permettre d’accéder aux ressources de votre projet depuis le projet integration_tests.

# your_dbt_project/integration_tests/packages.yml

packages:
  - local: ../

Une dernière chose avant de passer à l'écriture des tests unitaires. Il faut définir l’ordre de résolution des macros de sorte à ce que les macros internes de dbt que vous aurez surchargées dans votre projet soient bien résolu dans le projet integration_tests. Pour cela, ajoutez les lignes suivantes dans votre fichier your_dbt_project/integration_tests/dbt_project.yml :

dispatch:
  - macro_namespace: dbt
	search_order: ['integration_tests', 'your_dbt_project', 'dbt'] # Remplacez bien your_dbt_project par le nom de votre projet

Tests unitaires

Vos tests unitaires seront des macros ne prenant pas de paramètres. Voici les différentes étapes d’un test unitaire :

  1. Déclarer les paramètres utilisés pour tester votre macro
  2. Déclarer l’attendu pour le retour de la macro
  3. Appeler la macro avec les paramètres déclarés à l’étape 1
  4. Comparer le retour de la macro avec l’attendu déclaré à l'étape 2. Pour cela, je vous conseille d’utiliser le package dbt_unittest. Celui-ci contient des macros permettant de facilement réaliser des asserts.

Voilà à quoi pourrait ressembler le test unitaire pour ma macro cast_columns_implicit_return déclarée plus haut :

{% macro test_cast_columns_implicit_return() %}
	{# Paramètres à utiliser pour le test #}
	{% set source_table = 'table_src' %}
	{% set cast_dict = {'int': ['int_col_a', 'int_col_b'], 'boolean': ['bool_col_a']} %}

	{# Ce que doit retourner la macro #}
	{% set expected_result %}
    	select
        	cast(int_col_a as int) as int_col_a
  		 	, cast(int_col_b as int) as int_col_b
        	, cast(bool_col_a as boolean) as bool_col_a
    	from table_src
	{% endset %}

	{# Appel de la macro #}
	{% set result = your_dbt_project.cast_columns_implicit_return(source_table, cast_dict) %}

	{# Assert que le résultat est bien égal à l'attendu #}
	{% do assert_sql_equals(result, expected_result) %}
{% endmacro %}

A la lecture de ce test, je vous sens quelque peu énervé :

Ce test ne passera jamais ! Tu nous mets en garde contre la gestion des espaces mais tu fais n’importe quoi ! Tu utilises un bloc set pour ton expect et le retour de ta macro est un vrai gruyère ! Et puis tu n’utilises même pas le package que tu nous conseilles pour faire l’assert. C’est du grand n’importe quoi cet article…

Ne partez pas tout de suite, je vais vous expliquer mon secret 😀

Le secret de la gestion des espaces

Mesdames et Messieurs, voici le clou de cet article. Le passage tant attendu qui a été maintes fois annoncé tout au long de cette lecture. Voici comment tester les requêtes ou portion de requêtes SQL renvoyées par votre macro.

Le secret réside dans la macro assert_sql_equals. Avant de comparer les chaînes de caractères qu’elle prend en paramètre, elle va les passer à la moulinette afin de les formater tel que :

  1. elle remplace les retours à la ligne par des espaces
  2. elle retire les espaces/tabulations après les parenthèses ouvrantes et avant les parenthèse fermantes
  3. elle retire les espaces/tabulations avant et après les virgules
  4. elle retire les espaces/tabulations avant et après les opérateurs mathématiques
  5. elle remplace toutes les suites d’espaces/tabulations contiguës par un unique espace
  6. elle retire les espaces/tabulations en début et fin de chaîne de caractères
  7. elle passe la chaîne de caractères en minuscules

Vous pouvez faire ces opérations assez facilement grâce à des regex. dbt expose le module re de Python via la variable modules.re.

{% macro remove_line_breaks(input_string) %}
	{% do return(input_string | replace('\n', ' ')) %}
{% endmacro %}


{% macro format_parenthesis(input_string) %}
	{% set re = modules.re %}
	{% set opening_parenthesis_formated = re.sub('\([ \t]*', '(', input_string) %}
	{% set closing_parenthesis_formated = re.sub('[ \t]*\)[ \t]*', ') ', opening_parenthesis_formated) %}
	{% do return(closing_parenthesis_formated) %}
{% endmacro %}


{% macro format_commas(input_string) %}
	{% do return(modules.re.sub('[ \t]*,[ \t]*', ', ', input_string)) %}
{% endmacro %}


{% macro format_math_operators(input_string) %}
	{% do return(modules.re.sub('[ \t]*(!?=|<>|<=?|>=?|\|\|)[ \t]*', ' \g<1> ', input_string)) %}
{% endmacro %}


{% macro format_whitespaces(input_string) %}
	{% do return(modules.re.sub('\s{2,}', ' ', input_string) | trim) %}
{% endmacro %}


{% macro format_sql(input_string) %}
	{% set without_line_break = remove_line_breaks(input_string) %}
	{% set with_formated_parenthesis = format_parenthesis(without_line_break) %}
	{% set with_formated_commas = format_commas(with_formated_parenthesis) %}
	{% set with_formated_math_operators = format_math_operators(with_formated_commas) %}
	{% set with_formated_whitespaces = format_whitespaces(with_formated_math_operators) %}
	{% do return(with_formated_whitespaces | lower) %}
{% endmacro %}

{% macro assert_sql_equals(macro_output, expected_macro_output) %}
	{% set formated_macro_output = format_sql(macro_output) %}
	{% set formated_expected_macro_output = format_sql(expected_macro_output) %}
	{% do dbt-unittest.assert_equals(formated_macro_output, formated_expected_macro_output) %}
{% endmacro %}

Bien entendu vous pouvez implémenter des tests unitaires pour les macros qui vous permettent de tester vos macros 🙃.

Voilà c’est tout ! Plutôt simple, non ? Il ne manque plus qu’une dernière étape.

Appeler vos tests unitaires

Pour éviter de lancer tous vos tests unitaires un à un, on va les appeler dans une macro.

{% macro run_unit_tests() %}
	{% do test_cast_columns_implicit_return() %}
	{% do test_cast_columns_explicit_return() %} {# Pas définie dans cet article. Mais similaire à test_cast_columns_implicit_return #}
{% endmacro %}

On va ensuite pouvoir déclencher tous nos tests avec une unique commande :

dbt run-operation run_unit_tests

Vous pourrez lancer cette commande directement depuis votre poste ou même dans un pipeline de CICD. Pratique, non ?

Conclusion

Voilà comment tester vos macros. C’est facile à mettre en place, ça va vous faire gagner beaucoup de temps et vous économiser des nœuds au cerveau. En plus, ça peut aider d'autres développeurs à comprendre ce que font vos macros. Il n’y a que des avantages donc vous n’avez aucunes excuses pour ne pas le faire.

Pour aller plus loin sur les tests de vos projets dbt, vous pouvez vous pencher sur les tests unitaires de transformation. Cette feature est encore en bêta et devrait sortir avec la version 1.8 de dbt-core. D’ailleurs, on est en train de vous concocter un petit article dessus. A la prochaine 👋