Blog Arolla

Fixtures qui se ressemblent, builder qui les assemble

Les fixtures Pytest sont des fonctions qui permettent de définir un contexte pour les tests de façon cohérente, fiable, réutilisable et simple. C'est une fonctionnalité offerte par le fameux framework de test Pytest, adulée par beaucoup de développeurs Python.

Comme beaucoup de fonctionnalités de Pytest, les fixtures sont conçues pour simplifier l'écriture et la maintenance des tests. Pour cette raison, il y a beaucoup d'avantages à s'en servir. Ceci dit, quand un outil est pratique, on est tenté de s'en servir tout le temps, quitte à malicieusement en abuser ! Et malheureusement, la plupart du temps, on ne s'en rend pas compte.

En fait, la qualité du code n'est pas toujours proportionnelle à notre volonté de bien faire. Partant de ces faits, nous allons aborder dans ce tutoriel un cas typique d'abus accidentel des fixtures ainsi qu'un moyen d'y remédier.

Mise en situation

Disclaimer: Ceci est une histoire créée de toute pièce par l'auteur de cet article afin d'illustrer la problématique mise en avant

Pâtes et application

Alfred (Fredo pour les amis), développeur Python, travaille sur l'application web du restaurant CopyPasta. Chez CopyPasta, on peut composer soi-même son assiette de pâtes avec différents ingrédients. A ce propos, les clients doivent choisir :

  • La forme des pâtes (fusilli, spaghetti ou penne)
  • La sauce (bolognaise, chili ou carbonara)
  • Topping (poulet, bœuf ou champignon)

Puis, le service de cuisine récupère la commande une fois que le client l'a passée.

Le code d’un développeur vaillant

Crafter, ce bon vieux Fredo a écrit des tests pour le processus de récupération de commande. En plus, il l'a fait en TDD l'ami Fredo, tellement il tenait à la qualité de son code. Voici par exemple les tests qu'il a écrits pour la récupération de commande :

Remarque
Les tests utilisés dans ce tutoriel ainsi que le code de production associé se trouvent sur le repo suivant : https://github.com/ericdasse28/fredo-restaurant

tests/test_cooking_service.py

import pytest

from fredo_restaurant.cooking_service import CookingService
from fredo_restaurant.dish import PastaType, Salsa, Topping
from fredo_restaurant.order import Order


@pytest.fixture
def penne_carbonara_order():
    penne_order_content = {
        "pasta": PastaType.PENNE,
        "salsa": Salsa.CARBONARA,
        "topping": None,
    }

    order = Order(
        order_content=penne_order_content,
        location="The place over there!",
        comment="Some blabla",
        customer_name="Aline",
    )

    return order


@pytest.fixture
def penne_carbonara_with_mushrooms():
    penne_carbonara_with_mushrooms = {
        "pasta": PastaType.PENNE,
        "salsa": Salsa.CARBONARA,
        "topping": Topping.MUSHROOMS,
    }

    order = Order(
        order_content=penne_carbonara_with_mushrooms,
        location="The place over there!",
        comment="Some blabla",
        customer_name="Aline",
    )

    return order


@pytest.fixture
def spaghetti_bolognaise_order():
    spaghetti_bolognaise_content = {
        "pasta": PastaType.SPAGHETTI,
        "salsa": Salsa.BOLOGNAISE,
        "topping": None,
    }

    order = Order(
        order_content=spaghetti_bolognaise_content,
        location="The place over there!",
        comment="Some blabla",
        customer_name="Axel",
    )

    return order


def test_cooking_service_can_take_penne_carbonara_order(penne_carbonara_order):
    cooking_service = CookingService()

    dish_to_cook = cooking_service.take_order(penne_carbonara_order)

    assert dish_to_cook.pasta == PastaType.PENNE
    assert dish_to_cook.salsa == Salsa.CARBONARA
    assert dish_to_cook.topping is None


def test_cooking_service_can_take_spaghetti_bolognaise_order(
    spaghetti_bolognaise_order,
):
    cooking_service = CookingService()

    dish_to_cook = cooking_service.take_order(spaghetti_bolognaise_order)

    assert dish_to_cook.pasta == PastaType.SPAGHETTI
    assert dish_to_cook.salsa == Salsa.BOLOGNAISE
    assert dish_to_cook.topping is None


def test_cooking_service_can_take_penne_carbonara_with_mushrooms_order(
    penne_carbonara_with_mushrooms,
):
    cooking_service = CookingService()

    dish_to_cook = cooking_service.take_order(penne_carbonara_with_mushrooms)

    assert dish_to_cook.pasta == PastaType.PENNE
    assert dish_to_cook.salsa == Salsa.CARBONARA
    assert dish_to_cook.topping == Topping.MUSHROOMS

Tel quel, ce code fait très bien son travail. Qui plus est, les tests passent tous avec succès, signifiant ainsi que le code de production se comporte comme attendu. Pourtant ...

What’s the sitch?

Nous avons là un exemple typique d'abus de fixtures. En dépit de ses bonnes intentions, on peut remarquer que les fixtures de Fredo ont quasiment le même comportement. Elles diffèrent uniquement par les données qu'elles véhiculent, comme des gobelets avec des boissons différentes à l'intérieur.

D'autant plus que si le restaurant se met à proposer davantage de types différents de pâtes, de sauce ou de topping, il va finalement devoir écrire beaucoup de code très répétitif. Devoir se répéter, surtout autant, c'est un smell.

D'ailleurs, Fredo a bien remarqué cet axe d'amélioration lui aussi. Cependant, il ne voit pas comment faire mieux. A juste titre, le pauvre Fredo s'est tellement creusé les méninges pour trouver une meilleure approche que dans son cerveau, il y a actuellement un trou de la taille du canal de Suez !

Peut-être êtes-vous dans une situation similaire à celle de Fredo ? Attendez, c'est peut-être même vous Fredo 😲?!

Dans tous les cas, nous sommes certainement arrivés à la même conclusion : on ne laissera pas tomber notre ami. Et on ne vous laissera pas tomber non plus 😉

Sans tarder, nous allons voir une tactique pour éviter d'écrire des fixtures qui se ressemblent comme les bigoudis de tatie Margarette le dimanche matin.

Comment rectifier le tir ?

Observation

Dans un système, toute connaissance doit avoir une représentation unique, non ambiguë, faisant autorité

Les fixtures dans le code précédent sont en réalité une répétition l'une de l'autre.

Pourtant c'est bien connu, nous autres bons fainéants développeur.se.s, on n'aime pas se répéter ! Cependant ne vous méprenez pas. Ce n'est pas (toujours) un caprice, mais plutôt une attitude motivée par le principe DRY.

Il s'agit d'un principe de développement logiciel qui stipule que "Dans un système, toute connaissance doit avoir une représentation unique, non ambiguë, faisant autorité". Ce principe est essentiel pour la maintenabilité, la testabilité, le débogage et les évolutions d'une application donnée. Par exemple, il nous évite qu'une modification dans une partie du code implique la même à d'autres endroits. Dans cet esprit, il permet un gain de productivité et réduit le risque d'oublis ou d'erreur humaine (ou animale s'il y a des chiens et des chats codeurs ici, on ne sait jamais).

Pour ce qui est de notre cas précis, chaque fixture représente une commande que le service de cuisine doit récupérer. Il est donc question de généraliser la manière dont on crée une commande pour les tests de ce module.

Solution

Le design pattern Builder à la rescousse

D'une fixture à l'autre, le contenu de la commande est la seule information pertinente à changer. On pourrait donc "construire" une commande de test uniquement à partir de son contenu. Il s'ensuit qu'on remplirait les autres champs par des valeurs sans importance.

En d'autres termes, il s'agit de s'inspirer du design pattern Builder.

Concrètement, voici comment nous procéderions.

Tout d'abord, nous allons créer une fonction make_test_order comme suit :

tests/test_cooking_service.py

def make_test_order(pasta, salsa, topping=None):
    order_content = {
        "pasta": pasta,
        "salsa": salsa,
        "topping": topping,
    }

    order = Order(
        order_content=order_content,
        location="The place over there!",
        comment="Some blabla",
        customer_name="Veronica",
    )

    return order

Ensuite, nous allons mettre notre hypothèse à l'épreuve. Nous allons "construire" les commandes dans les tests à l'aide de cette fonction. Commençons par les commandes de "penne carbonara".

tests/test_cooking_service.py

def test_cooking_service_can_take_penne_carbonara_order():
    cooking_service = CookingService()
    penne_carbonara_order = make_test_order(
        pasta=PastaType.PENNE,
        salsa=Salsa.CARBONARA,
    )

    dish_to_cook = cooking_service.take_order(penne_carbonara_order)

    assert dish_to_cook.pasta == PastaType.PENNE
    assert dish_to_cook.salsa == Salsa.CARBONARA
    assert dish_to_cook.topping is None

Lancez les tests pour vous assurer qu'ils passent toujours avec succès. Le cas échéant, faites cette modification sur les autres tests de récupération de commande.

tests/test_cooking_service.py

def test_cooking_service_can_take_spaghetti_bolognaise_order():
    cooking_service = CookingService()
    spaghetti_bolognaise_order = make_test_order(
            pasta=PastaType.SPAGHETTI,
            salsa=Salsa.BOLOGNAISE,
    )

    dish_to_cook = cooking_service.take_order(spaghetti_bolognaise_order)

    assert dish_to_cook.pasta == PastaType.SPAGHETTI
    assert dish_to_cook.salsa == Salsa.BOLOGNAISE
    assert dish_to_cook.topping is None


def test_cooking_service_can_take_penne_carbonara_with_mushrooms_order():
    cooking_service = CookingService()
    penne_carbonara_with_mushrooms = make_test_order(
            pasta=PastaType.PENNE,
            salsa=Salsa.CARBONARA,
            topping=Topping.MUSHROOMS,
    )

    dish_to_cook = cooking_service.take_order(penne_carbonara_with_mushrooms)

    assert dish_to_cook.pasta == PastaType.PENNE
    assert dish_to_cook.salsa == Salsa.CARBONARA
    assert dish_to_cook.topping == Topping.MUSHROOMS

En exécutant à nouveau les tests, vous devriez constater qu'ils passent toujours tous sans exception. Désormais, vous pouvez supprimer toutes les fixtures étant donné qu'elles ne nous sont plus d'aucune utilité.

Résultat final

Le code des tests de récupération de commande ressemble dorénavant à ceci :

tests/test_cooking_service.py

from fredo_restaurant.cooking_service import CookingService
from fredo_restaurant.dish import PastaType, Salsa, Topping
from fredo_restaurant.order import Order


def make_test_order(pasta, salsa, topping=None):
    order_content = {"pasta": pasta, "salsa": salsa, "topping": topping}
    order = Order(
        order_content=order_content,
        location="The place over there!",
        comment="Some blabla",
        customer_name="Veronica",
    )

    return order


def test_cooking_service_can_take_penne_carbonara_order():
    cooking_service = CookingService()
    penne_carbonara_order = make_test_order(
        pasta=PastaType.PENNE,
        salsa=Salsa.CARBONARA,
    )

    dish_to_cook = cooking_service.take_order(penne_carbonara_order)

    assert dish_to_cook.pasta == PastaType.PENNE
    assert dish_to_cook.salsa == Salsa.CARBONARA
    assert dish_to_cook.topping is None


def test_cooking_service_can_take_spaghetti_bolognaise_order():
    cooking_service = CookingService()
    spaghetti_bolognaise_order = make_test_order(
        pasta=PastaType.SPAGHETTI,
        salsa=Salsa.BOLOGNAISE,
    )

    dish_to_cook = cooking_service.take_order(spaghetti_bolognaise_order)

    assert dish_to_cook.pasta == PastaType.SPAGHETTI
    assert dish_to_cook.salsa == Salsa.BOLOGNAISE
    assert dish_to_cook.topping is None


def test_cooking_service_can_take_penne_carbonara_with_mushrooms_order():
    cooking_service = CookingService()
    penne_carbonara_with_mushrooms = make_test_order(
        pasta=PastaType.PENNE,
        salsa=Salsa.CARBONARA,
        topping=Topping.MUSHROOMS,
    )

    dish_to_cook = cooking_service.take_order(penne_carbonara_with_mushrooms)

    assert dish_to_cook.pasta == PastaType.PENNE
    assert dish_to_cook.salsa == Salsa.CARBONARA
    assert dish_to_cook.topping == Topping.MUSHROOMS

Bien moins long pas vrai ? 🙂

Cette solution a un autre avantage qui plus est. Le contenu des commandes qui sont utilisées dans les tests est à l'intérieur du code de test concerné. C'est une grande aide en termes de lisibilité et de maintenabilité globale de ces tests.

Et voilà le travail ! La création de chaque commande dans cette nouvelle version des tests se fait en renseignant uniquement les informations pertinentes. Pas de répétition inutile qui tienne !


Note
La solution proposée ici se trouve sur la branche "solution" du repo https://github.com/ericdasse28/fredo-restaurant mentionné au début de ce tutoriel.


Ça y est ! Nous avons atteint notre objectif, nous avons sauvé le soldat Fredo. Vous pouvez être fiers de vous 🙂.

Take Away

En somme, les fixtures ont beaucoup d'avantages et dans plusieurs situations, il est pertinent de s'en servir. Toutefois, comme pour tout outil, il est important de faire preuve de discernement car elles ne sont pas nécessairement la solution à tout problème.

D'une façon générale, chaque outil est créé avec une intention particulière, avec ses forces et ses faiblesses. On cloue avec un marteau, on visse avec un tournevis. Nous pourrions essayer d'utiliser l'un pour faire la tâche de l'autre, mais on va vite se rendre compte que c'est très fastidieux. C'est pourquoi en tant que crafter, nous devons faire notre possible pour améliorer notre compréhension des techniques et des outils.

Je tiens également à préciser que la solution proposée dans cet article n'est pas absolue. Il y a probablement d'autres manières de procéder. N'hésitez donc pas à creuser la question si vous le souhaitez 🙂.

Sur ces derniers mots, j'espère sincèrement vous avoir inspiré des manières de contribuer à la qualité de votre code de test.

A bientôt pour un nouvel article 😉

Consultant Python/Data à Arolla | Plus de publications

Comments are closed.