Il est désormais communément admis que les les tests sont essentiels pour garantir le bon fonctionnement d’une application et faciliter sa maintenance. Ils permettent de s’assurer de la non-régression de certains comportements mais également de les documenter pour de futurs développeurs. Plus les tests sont lisibles et explicites, plus on est en mesure de comprendre comment le code fonctionne. Robert C. Martin, va jusqu’à déclarer dans son livre “Clean Code”1:

Test code is just as important as production code. It is not a second-class citizen. It requires thought, design, and care. It must be kept as clean as production code.

Ce qui donne en français:

Le code de test est aussi important que le code de production. Ce n’est pas un citoyen de seconde zone. Il nécessite réflexion, conception et soin. Il doit être maintenu aussi propre que le code de production.

La collecte de données étant au cœur de l’activité de Deepki, il est primordial de s’assurer que les fonctions qui persistent, formattent ou manipulent ces données sont testées de manière lisible. Dans cet article, nous nous intéresserons tout particulièrement aux fixtures pytest comme moyen de simuler des données de test. Et la conclusion n’est peut-être pas celle que vous attendez.

Mais trève de bavardage et rentrons sans plus tarder dans le vif du sujet !

Fixtures en haute mer

Les fixtures, kézako ?

Une fixture est un outil de développement qui permet de générer ou simuler des données, des configurations ou des applications externes. Les fixtures permettent d’initialiser un contexte cohérent et réutilisable d’un test à l’autre (par exemple en définissant un jeu de données de test). Les fixtures pytest (définies grâce au décorateur @pytest.fixture) correspondent à des fonctions appelées (explicitement ou non) via les arguments des fonctions ou classes de test. Elles sont particulièrement utiles pour mocker un service, une librairie externe, un client web ou une base de données.

Qui dit fixture dit test !

Mais rien de tel qu’un exemple concret pour mieux comprendre le fonctionnement de ces fixtures. Considérons une entreprise qui fabrique des voiliers et assure un suivi informatisé des bateaux qu’elle construit. Chaque voilier est identifié par les informations suivantes :

  • un identifiant unique
  • un nom
  • une année de construction
  • un type: catamaran, trimaran, monocoque (ou “singlehull” pour nos amis anglophones)

Pour l’instant l’exemple est très bateau je vous l’accorde.

On souhaite tester la fonction get_boat_description qui retourne la description textuelle d’un bateau, identifié par son id. Évidemment notre but est de rendre ces tests les plus explicites et concis possible. Puisqu’il s’agit de code Python, nous nous orientons assez naturellement vers les fixtures pytest.

La fixture sailboat définit un dictionnaire qu’il nous est ensuite possible d’utiliser directement comme argument de la fonction test_get_boat_description.

@pytest.fixture
def sailboat():
    return {
        "id": "id_sailboat",
        "name": "Santa Maria",
        "construction_year": 1492,
        "type_of_boat": "singlehull",
    }

def test_get_boat_description(sailboat):
    get_entity("boats").insert(sailboat)
    description = get_boat_description(id="id_sailboat")
    assert description == "Le Santa Maria est un voilier monocoque construit en 1492."

Le résultat est clair et concis, les données de tests sont explicites et définies à proximité du test ce qui nous permet une comparaison directe entre l’input et l’output de la fonction. Que demander de plus !

L’utilisation des fixtures nous permet également de réutiliser notre dictionnaire de données dans un autre test. Imaginons par exemple qu’il faille tester la fonction get_boats qui retourne la liste de tous les bateaux construits par l’entreprise.

def test_get_boat(sailboat):
    get_entity("boats").insert(sailboat)
    assert get_boats() == [{
        "id": "id_sailboat",
        "name": "Santa Maria",
        "construction_year": 1492,
        "type_of_boat": "sailboat",
    }]

Qu’en est-il si nous décidons de complexifier un peu notre exemple ?

Jusque-là, aucune ombre au tableau ! Mais supposons désormais que l’entreprise navale construit des bateaux à moteur en plus des voiliers. À des fins de simplification, on considèrera que les champs qui décrivent un bateau à moteur sont les mêmes que ceux utilisés pour décrire un voilier. La description variant légèrement entre les deux types de bateaux, nous voulons nous assurer que le nouveau comportement de la fonction get_boat_description correspond à celui attendu. Malheureusement, nous ne pouvons pas réutiliser la fixture sailboat. Nous sommes contraints de définir une nouvelle fixture motorboat propre aux données que l’on souhaite tester.

@pytest.fixture
def motorboat():
    return {
        "id": "id_motorboat",
        "name": "Titanic",
        "construction_year": 1909,
        "type_of_boat": "motorboat",
    }

def test_get_boat_description_for_motorboat(motorboat):
    get_entity("boats").insert(motorboat)
    description = get_boat_description(id="id_motorboat")
    assert description == "Le Titanic est un bateau à moteur construit en 1909."

Notre fichier de test commence déjà à grossir. Pour vérifier que la description est conforme aux informations du bateau recherché, il faut se référer à la fixture associée. Plus le nombre de fixtures est grand, plus la gymnastique entre les tests et les données générées par chaque fixture devient ardue.

Jamais deux sans trois fixtures ?

Ajoutons une nouvelle subtilité. On souhaite distinguer les bateaux qui ont coulé de ceux qui naviguent encore. La fonction get_number_of_boats_that_sunk retourne le nombre de bateaux construits par l’entreprise et qui ont coulé (en se fondant sur le champ has_sunk).

Une fois encore, les fixtures implémentées dans les tests décrits précédemment ne permettent pas de tester la fonction get_number_of_boats_that_sunk. Deux solutions s’offrent à nous :

  1. Définir une troisième fixture
  2. Réutiliser une fixture existante que l’on modifiera dans notre test pour se conformer au besoin

Option 1: Définition d’une nouvelle fixture motorboat_that_sunk comportant le champ has_sunk

@pytest.fixture
def motorboat_that_sunk():
    return {
        "id": "id_motorboat",
        "name": "Titanic",
        "construction_year": 1909,
        "type_of_boat": "motorboat",
        "has_sunk": True,
    }

def test_get_number_of_boats_that_sunk(sailboat, motorboat_that_sunk):
    get_entity("boats").insert_many([sailboat, motorboat_that_sunk])
    assert get_number_of_boats_that_sunk() == 1

Notez l’importante duplication entre les fixtures motorboat et motorboat_that_sunk. Ici, pour un seul champ qui change, quatre autres sont dupliqués. Cela rend la lecture du test plus compliquée (car l’information pertinente est noyée par le reste). Pour tester notre fonction, peu nous importe de savoir de quel type de bateau il s’agit ou en quelle année il a été construit !

Option 2: Réutilisation puis modification d’une fixture existante

Une autre solution consiste à réutiliser la fixture motorboat en ajoutant un champ has_sunk égal à True.

def test_get_number_of_boats_that_sunk(sailboat, motorboat):
    motorboat_that_sunk = {
        **motorboat,
        "has_sunk": True,
    }
    get_entity("boats").insert_many([sailboat, motorboat_that_sunk])
    assert get_number_of_boats_that_sunk() == 1

On perd à nouveau en lisibilité à cause des détours de code que nous imposent cette solution. Le lecteur est obligé de se référer à la fixture motorboat alors qu’aucun des champs qui la compose ne nous intéresse. On a détourné l’usage initial de la fixture, et pire encore, on déroge à la sacrosainte règle de lisibilité !

Écrire les tests ainsi rend plus difficile leur prise en main. On risque également de faire apparaître des effets de bord, causés par les autres champs de la fixture utilisée et qui ne seraient pas censés impacter la fonction testée mais dont on aurait oublié de changer la valeur.

Les fixtures c’est pas automatique

Ces quelques exemples mettent en lumière une vérité : les fixtures ne sont pas adaptées à tous les besoins. Lorsque les données manipulées sont complexes ou que le comportement des fonctions testées varie grandement en fonction de la valeur des champs, il est souvent nécessaire de multiplier le nombre de fixtures pour répondre à tous les cas de tests.

Et ne parlons pas des fixtures en autouse pour des données de test ! Ces fixtures n’ont pas besoin d’être appelées explicitement au sein des fonctions de test pour être invoquées. On a alors l’impression que les données sont instanciées par magie ce qui invisibilise complètement les champs manipulés. Utiliser des fixtures de données en autouse, c’est ouvrir la porte aux effets de bord que les développeurs peuvent parfois mettre longtemps à identifier.

Ce qui nous manque avec ces fixtures, c’est davantage de flexibilité et notamment la possibilité de paramétrer nos données. Le problème est que pytest rend cette paramétrisation complexe puisqu’elle nécessite de faire appel à @pytest.mark.parametrize et aux paramètres indirects. Cela alourdit la syntaxe et ne résout guère notre problème de clarté de code.

Mais alors que faire pour éviter le naufrage ? Comment concilier lisibilité des tests, limitation de la duplication de code et documentation des données manipulées? Eh bien tout simplement en revenant à l’essentiel : nos bonnes vieilles fonctions !

Un test, un bateau, une fonction d’initialisation de données

Nous sommes tellement habitués à tester l’implémentation de nouveaux frameworks tendance ou à manipuler des concepts complexes que l’on en oublie bien souvent la base de l’informatique. Car une fonction, cela reste une portion de code, que l’on peut invoquer plusieurs fois, avec potentiellement des paramètres et qui effectue une action ou retourne un résultat en fonction de ces paramètres.

C’est bien ce que l’on souhaite faire ici (en dépit de la simplicité apparente): accéder, depuis nos tests, à des données qui auraient été générées pour des cas de test (et donc en fonction des valeurs passées en argument). Prenons un peu de recul et essayons de simplifier tout cette histoire de tests. Dans le cas où seuls des voiliers sont persistés, l’utilisation de fonctions donne :

def new_boat(id: str, name: str, construction_year: int, type_of_boat: str):
    return {
        "id": id,
        "name": name,
        "construction_year": construction_year,
        "type_of_boat": type_of_boat,
    }

def test_get_boat_description():
    boat = new_boat(id="id_sailboat", name="Santa Maria", construction_year=1492, type_of_boat="singlehull")
    get_entity("boats").insert(boat)
    description = get_boat_description(id="id_sailboat")
    assert description == "Le Santa Maria est un voilier monocoque construit en 1492."

L’initialisation des données est certes légèrement plus longue que dans le cas des fixtures (une ligne de plus) mais elle est également bien plus explicite. À la lecture du test on identifie immédiatement les différentes parties qui le composent :

  1. l’initialisation des données de test
  2. l’appel à la fonction à tester
  3. l’assertion sur le résultat (qu’on peut comparer immédiatement avec les données en entrée)

Le test se suffit à lui-même et l’on n’a pas besoin de se référer à la fonction new_boat pour comprendre ce que retourne la fonction get_boat_description. Tout ce que l’on teste dans l’output apparaît explicitement dans l’input.

Mais une fonction réutilisable !

Ce gain de lisibilité est d’autant plus évident lorsque l’on rajoute un nouveau test sur des données de bateaux à moteur. Il nous suffit en effet d’appeler la fonction new_boat avec des paramètres différents (sans même avoir à la modifier) :

def test_get_boat_description_for_motorboat():
    motorboat = new_boat( id="id_motorboat", name="Titanic", construction_year=1909, type_of_boat="motorboat")
    get_entity("boats").insert(motorboat)
    description = get_boat_description(id="id_motorboat")
    assert description == "Le Titanic est un bateau à moteur construit en 1909."

L’avantage des paramètres par défaut

Enfin, si l’on souhaite tester un seul des champs de notre document (ici le champ has_sunk), il n’est plus nécessaire de dupliquer du code. En ajoutant des valeurs par défaut aux paramètres de notre fonction new_boat, on a la possibilité de ne passer en argument que les valeurs des champs que l’on souhaite tester, rendant ainsi très explicite le cas de test.

def new_boat(
        id: str = "boat_id",
        name: str = "boat_name",
        construction_year: int = 1970,
        type_of_boat: str = "sailboat",
        has_sunk: bool = False,
):
    return {
        "id": id,
        "name": name,
        "construction_year": construction_year,
        "type_of_boat": type_of_boat,
        "has_sunk": has_sunk,
    }

def test_get_number_of_boats_that_sunk():
    boat = new_boat(id="boat_still_floating", has_sunk=False)
    boat_that_sunk = new_boat(id="boat_that_sunk", has_sunk=True)
    get_entity("boats").insert_many([boat, boat_that_sunk])
    assert get_number_of_boats_that_sunk() == 1

Un comparatif des deux versions (et notamment du nombre de lignes de code requis par l’usage des fonctions ou des fixtures) est disponible ici.

Et si on veut aller plus loin ?

On peut évidemment complexifier ces fonctions pour les adapter à nos besoins. Par exemple en effectuant les interactions avec notre base de données directement au sein de la fonction pour ne pas avoir à répéter les insertions successives.

def persist_new_boat(id: str, name: str, construction_year: int, type_of_boat: str):
    boat = new_boat(id=id, name=name, construction_year=construction_year, type_of_boat=type_of_boat)
    get_entity("boats").insert(boat)

def test_get_number_of_boats_that_sunk():
    persist_new_boat(id="boat_still_floating", has_sunk=False)
    persist_new_boat(id="boat_that_sunk", has_sunk=True)
    assert get_number_of_boats_that_sunk() == 1

On peut également manipuler des structures de données plus complexes, voire introduire la notion de Dataclass pour vérifier le format des données testées. La rigueur imposée par ce format nous permet de mieux documenter nos modèles de données.

Plus ces fonctions seront utilisées par les tests, plus elles deviendront robustes. Chaque cas de test permettra de complexifier la logique d’insertion de données pour prendre en compte cette diversité de champs et de valeurs. C’est à l’usage qu’elles seront éprouvées, permettant ainsi de documenter un peu plus chaque jour les cas possibles et les comportements attendus. Je ne saurais que vous conseiller de commencer par des fonctions très simples quitte à rajouter des champs ou des exceptions au fur et à mesure que l’application (et donc vos tests) évolue.

C’est à ce moment là que les Factories peuvent entrer en jeu. Elles permettent d’instancier plusieurs types d’objets différents (qui donneront lieu à des comportements différents dans les tests) à partir d’une même classe abstraite. Ce design pattern est particulièrement adapté aux données complexes telles que celles collectées chez Deepki.

Le mot de la fin

On l’a vu, l’utilisation des fixtures pytest soulève un certain nombre de problèmes. On ne peut pas les paramétrer facilement pour correspondre à différents cas de test ce qui nous oblige souvent à dupliquer le code. La lisibilité des tests en est également impactée. Les fixtures se fondent sur une instanciation implicite des données de test qui nous impose souvent des allers-retours entre les fixtures et les tests qui les invoquent (les deux étant parfois définis dans des fichiers distincts). Les tests ne se suffisent plus à eux-même (et dépendent de données “magiques”) alimentées par les fixtures) et c’est ce qui me déplaît le plus ici.

Retourner aux basiques pour initialiser les données de test est bien souvent préférable. Les simples fonctions, si elles sont découpées correctement, nommées de manière explicite et conformes à la réalité du modèle de données manipulé suffisent généralement à représenter la diversité des cas que l’on souhaite tester. Leur flexibilité, leur facilité d’utilisation et leur lisibilité sont autant d’atouts à faire pâlir les fixtures.

Vive la simplicité, vive la lisibilité… et vive les fonctions !

  1. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship, Pearson, 2008