Patrice Ferlet
Patrice Ferlet
Créateur de ce blog.
Publié le juin 18, 2014 Temps de lecture: 9 min

Me suivre sur Mastodon

Générateur de tests Python pour nose

Python, j’en parle beaucoup, mais je l’utilise aussi pas mal. Faut dire, je bosse à 99% du temps sur ce langage. Et aujourd’hui, avec mon acolyte Jonathan Dray, on a réalisé un module qui nous a fait un peu plancher, mais qui apporte beaucoup à notre projet: des tests automatiques, c’est-à-dire générés. Ca peut paraitre touchy, overkill ou tout les mots à la mode que vous voulez, je vous assure (et vous allez le voir) que ça peut vous apporter beaucoup…

Au début, il y’a avait un beau foutoir

Je vous passe les détails. On a un module qui demande des tests hebdomadaires (parce que c’est un peu long à traiter). Pour simplifier l’explication: on parle des données sur le web, pour s’assurer que nos règles de parsing restent valables j’avais imaginé ce genre d’archi:

expected/
    siteA.py
    siteB.py
    siteC.py
    ...
test_SiteA.py
test_SiteB.py
test_SiteC.py
...

Dans “expected.siteA” on a un truc du genre:

data = {'un titre de test'; {
        'url' : 'http://...',
        'expected' : {
            # ... des datas traitées depuis cet url
        }
    },
    {'un autre titre': {
        #idem qu'au dessue, une url, des expected
        }
    }
}

La raison pour laquelle j’avais sorti les “expected” (données attendues) c’est que les données à tester sont relativement lourdes… Du coup, dans les tests unutaire on avait (en résumé):

from expected import siteA
# un truc de base qui gère des trucs redondants
from tests import testbase

# la class de test...
class TestSiteA(testbase):

    def test_url1(self):
        parsed = self.parse(siteA.data['un titre de test']['url'])
        self.assertEquals(parsed, siteA.data['un titre de test']['expected'])

Et ce, répété pour chaque site, pour chaque url du dictionnaire “data” contenu dans les module… bref, imbuvable même sous la contrainte. Jonathan pète un câble et me dit “non mais c’est complètement con, on copie colle là”. Et hors de question de faire une boucle… pouquoi ? c’est simple comme choux: on utilise nose, on va perdre des options cool.

Pas de boucle, et pour cause

Je reviens à la dernière remarque. Si Jonathan n’avait pas été là, j’aurais bêtement fait un truc qui charge toutes les données “expected”, et puis roulez jeunesse. Mais si un test foire, chaque appel à “nosetests” m’aurait rejoué toute la séquence.

Parce que voilà, nose est cool, il permet de faire ce genre de chose:

nosetests -sv test:TestSiteA.test_url1

Alors le truc à faire, c’est donc de savoir générer les tests… mais vous l’aurez compris, on ne va pas réellement les générer en tant que fichier, non, car Python est une tuerie en terme de réflexion et metaclasse. On va lire de la doc et créer un truc qui fait ça à la mode bourinnasse.

Faut que vous rencontriez ce type()

Vous me comprennez, je veux pouvoir utiliser “nose” comme si j’avais créé les classes nécessaires, sachant qu’elles font toutes à peu près la même chose. Le tout, c’est d’avoir un nom de classe consistant, et des méthodes de tests qui correspondent à peu près au titre des données attendues.

Générer une classe est ultra simple:

cl = type(nom, (parent1,...), {propriétés...})

“cl” sera la classe elle-même, et je dis bien “la classe”, et non un objet. Ca sent bon ça, mais le souci, c’est que je dois l’assigner au module courant. Toujours aussi simple, on utilise le module “sys” qui fourni la liste des modules chargés, y compris celui en cours. Et comment on connait le nom du module courrant ? Bha oui: __name__

Voyons voir:

import sys
import unitest

class BaseTest(unittest.TestCase)
    """ Classe de base, on y reviendra """
    pass

def init():
    # hop une référence au module en cours
    current = sys.modules[__name__]

    # génère une classe pour faire mumuse
    cl = type("Classe1", (BaseTest,), {})

    # on l'assigne au module en cours
    setattr(current, cl.__name__, cl)

init()
import pdb; pdb.set_trace()

Si vous sauvez ça dans “./tests/__init__.py” et que vous exécutez:

nosetests -sv tests 

Vous vous retrouvez dans une console pdb, tapez simplement:

dir(sys.modules[__name__])

et là, miracle, vous voyez bien que votre module a une classe nommée “Classe1”. Bha on continue !

OK, mais pour les expected là…

Bon c’est cool mais on va passer aux choses sérieuses. Je veux générer:

  • une classe par module expected
  • une méthode pour chaque url

Avoir la liste des modules a été le plus tordu… c’est pkgutil. Et pour m’assurer d’avoir bien le chemin du module expected, j’ai usé de ruse:

# on prend le chemin exact
pkgpath = os.path.dirname(expected.__file__) 

# et on a la liste des modules
for mod in pkgutil.iter_modules([pkgpath]):
    # on prend juste le nom:
    modname = mod[1]

Donc là, en visitant https://docs.python.org/2/library/pkgutil.html#pkgutil.iter_modules vous verrez que le nom du module est le second élément du tuple retourné par “iter_modules”, c’est pourquoi j’ai récupéré le nom dans “mod[1]”.

Va pour générer le nom de classe…

# on prend le chemin exact
pkgpath = os.path.dirname(expected.__file__) 

# et on a la liste des modules
for mod in pkgutil.iter_modules([pkgpath]):
    # on prend juste le nom:
    modname = mod[1]
    cl = type("Test%s" % modname, (BaseTest,), {}

    # par exemple, on aura TestSiteA pour le module expected.SiteA
    # etc...

Ok… on va générer les méthodes, on s’y prend un peu comme pour la classe. Seule problème, la méthode doit faire des trucs hein… et surtout utiliser les méthodes d’assertion. Et là, vous remerciez le ciel que Python demande de passer explicitement la référence de la classe à ces méthodes. Car de cette manière, on peut déclarer une fonction hors classe qui prend en argument un paramètre “self”, puis l’attacher à une classe. Du coup, “self” sera bel et bien défini à la déclaration, et utilisée par la classe. Haaaa douce mélodie qu’est le code Python.

On se lance.

# un générateur de méthode, c'est cool ça
def _method(data):
    # on défini la méthode qui gère "data"
    def the_method(self):
        # oui, python est magique, on a un self là
        # donc là, vous pouvez créer votre test générique, nous
        # on utilise notre parser
        parsed = self.parse(data['url'])
        self.assertEquals(parsed, data['expected'])
    return the_method

Donc, si j’appelle:

method = _method(data)

la variable “method” sera une instance “the_method” qui peut utiliser les “data”. Et quant à “self”, il sera refourgué par l’appel à la méthode au travers de la classe. Voilà une raison toute bête de l’utilité de déclarer explicitement le mot clef “self” dans les méthodes de classe en Python, c’est simplement parce que vous pouvez générer des méthodes en dehors des classes, et par conséquent, la variable “self” doit exister dans le corps de la fonction. Donc, on la file en argument. C’est pas compliqué boudiou !!!

Et donc, là, on va l’utiliser. Après avoir généré la classe, on peut générer la méthode et l’accrocher à la classe (cherchez “==> LA” dans le code pour trouver où on génère la méthode):

# et on a la liste des modules
for mod in pkgutil.iter_modules([pkgpath]):
    # on prend juste le nom:
    modname = mod[1]

    # BaseTest c'est notre classe de base qui hérite de unittest.TestCase
    # Et donc là, on aura par exemple TestSiteA, classe qui hérite de notre
    # classe de base
    cl = type("Test%s" % modname, (BaseTest,), {}

    # on charge le module, on demande de charger "data" avec...
    module = __import__(modname, globals(), locals(), ["data"])

    # on itère sur les modules
    for key, val in module.data.items():

        # on génère le nom sans espace
        # par exemple "un titre de test" devient
        # un_titre_de_test (et je me fous des accents pour le moment)
        key = key.replace(" ","_") 

        # on récupère une méthode qui va utiliser
        # nos datas
        # ==> LA
        method = _method(val)

        # on génère le nom de la méthode
        # ce qui donnera "test_un_titre_de_test"
        method.__name__ = "test_%s" % key

        # hop, on assigne ça à la classe
        setattr(cl, method.__name__, method)

    #et voilà, on foure notre classe au module
    setattr(sys.module[__name__], cl.__name__, cl)

Si vous avez tout suivi:

  • on load expected
  • pour chaque module trouvé dans expected on récupère le nom et on génère une classe
  • on charge les données de test
  • pour chaque données de test, on génère une méthode qui utilise les assertions de unittest…

Donc, le code à peu près valide (oui là je le teste pas et comptez pas sur moi pour vous refourguer le code de mon client hein :p ):

import unittest
import sys
import pkgutil
import expected

class BaseTest(unittest.TestCase):
    """ Classe de base, elle peut faire un setUp, un tearDown
        et avoir des méthodes spécifique, comme "parse" dans notre
        cas... 
    """ 

    def parse(seld, data):
        # méthode à nous pour parser une url
        return {}

def init():
    """ Initialise les classes de test """

    # notre générateur de méthode
    def _method(data):

        def the_method(self):
            # le test en lui même
            # libre à vous de faire vos propres test ICI
            parsed = self.parse(data['url'])

            #assertion qui viendra de unittest.TestCase
            self.assertEquals(parsed, data['expected'])

        # retourne la fonction de test
        return the_method

    # on prend le chemin exact
    pkgpath = os.path.dirname(expected.__file__) 

    # et on a la liste des modules
    for mod in pkgutil.iter_modules([pkgpath]):

        # on prend juste le nom:
        modname = mod[1]

        # génération de la classe avec le nom du module
        cl = type("Test%s" % modname, (BaseTest,), {}

        # on charge le module, on demande de charger "data" avec...
        module = __import__(modname, globals(), locals(), ["data"])

        # pour chaque module expected
        for key, val in module.data.items():

            # on génère le nom sans espace
            key = key.replace(" ","_") 

            # génération de la méthode test_nom_du_test
            method = _method(val)
            method.__name__ = "test_%s" % key

            # assignation dans la classe
            setattr(cl, method.__name__, method)

        # déclaration de la méthode dans le module
        setattr(sys.module[__name__], cl.__name__, cl)

# initialisation
init()

Donc, là… on peut faire:

nosetests -sv tests

qui va lancer tous les tests générés.

Ou:

nosetests -sv tests:TestSiteA.test_un_titre_de_test

qui ne lancera que le test “un titre de test” choppé depuis le module expected.SiteA.py

Ou encore:

nosetests -sv tests:TestSiteB

qui lancera tous les tests de TestSiteB.

Heu… ok… mais du coup ?

Vous n’avez pas compris l’intérêt ? je vais vous faire un résumé.

Avant on devait:

  • Créer un module qui contenait des données attendue, non placées dans le test pour des raison de place, de lisibilité…
  • Créer une classe de test qui:
  • charge le module expected.ModuleATester
  • charge le parser
  • fait les asserts

Alors que maintenant, on ne fait que la première partie… on créer un module dans “expected”, formatté corretement, et c’est tout. Notre générateur va trouver le nouveau module, les données avec le titre, et générer un test à la volée. On peut invoquer un test par son nom logique, on peut même demander à nose de n’exécuter qu’une seule méthode. Car la méthode existera réellement, Python génère les classes comme si nous les avions réellement écrites.

Si ça vous parait étrange, trop “brutal”, demandez vous comment vous auriez fait pour réussir le même tour de force avec les même contraintes, à savoir pouvoir utiliser nose “comme si les classes existaient vraiement”.

Depuis, on ne tape pas un seul test, on ne génère que le module de données à tester.

On a amélioré le système en permattant au développeur de spécifier des assertions particulères dans les modules de “expected”, et même la gestion des “skip”… le temps de création et de maintenance de ces tests est tout simplement réduit par 10.

comments powered by Disqus