Un gros guide bien gras sur les tests unitaires en Python, partie 5


Vous avez vu les modules pour faire les tests, mais dès que vous allez vouloir faire des tests sérieux, vous allez vous heurter à la dure réalité.

La réalité est que pour tester, il vous faut la réalité.

Par exemple, si vous tapez dans une base de données, il vous faut une base de données opérationnelle. Pour tester un téléchargement, il vous faut une connexion internet. Pour tester si votre API fonctionne, il faut lancer un serveur Web.

Autre chose, si votre code appelle un autre code, comment vous assurer que cet appel a bien eu lieu ? Il faudrait aussi un logger pour tous les appels, et si le code n’est pas le vôtre, c’est encore plus chiant.

Comme nous savons que les informaticiens sont des grosses larves, il y a forcément une solution, au moins partielle, à ces problèmes. En l’occurrence, on va jouer au docteur, à la dinette, aux cowboys et aux indiens.

Bref, on va jouer à faire semblant.

Les objets mocks

Un objet mock, c’est un objet basé sur le null object pattern qui sert à faire semblant. Quand on l’instancie avec n’importe quoi, ça marche, quand on appelle n’importe quelle méthode, ça marche et ça renvoie un mock.

Bien entendu, comme les besoins des tests sont un peu plus raffinés que ça, mock fait plus que du null object pattern, et permet :

  • De configurer son API.
  • De configurer ses sides effects.
  • De configurer sa valeur de retour.
  • De monkey patcher un autre objet.
  • D’enregistrer tous les appels qu’on lui fait.

Alors évidement, comme ça, je me doute bien que la puissance de l’outil ne vous frappe pas en face comme le nez au milieu de l’eureka dans un couloir.

C’est pour ça qu’on va passer aux exemples concrets. D’abord, assurez-vous de pouvoir faire import unittest.mock, qui est dispo depuis Python 3.3. Si ce n’est pas le cas, l’installer avec pip install mock vous permettra de l’importer sous la forme import mock. Le reste, c’est pareil.

On dirait que moi je t’attaque et toi tu meurs pas

Un objet mock est un callable, c’est-à-dire qu’il peut être appelé comme une fonction ou une classe, et il retourne toujours un objet mock :

>>> from unittest.mock import MagicMock # ou from mock import MagicMock
>>> mock = MagicMock()
>>> print(mock)
<MagicMock id='140302100559296'>
>>> mock()
<MagicMock name='mock()' id='140302101821704'>
>>> mock(1, True, [Exception, {}])
<MagicMock name='mock()' id='140302101821704'>

On peut appeler n’importe quoi sur son objet mock, et ça retourne toujours un objet mock :

>>> mock.foo()
<MagicMock name='mock.foo()' id='140302101723960'>
>>> mock.nimporte().nawak().je().te().dis()
<MagicMock name='mock.nimporte().nawak().je().te().dis()' id='140302101825744'>
>>> mock + mock - 10000
<MagicMock name='mock.__add__().__sub__()' id='140302134081520'>

Quand retourner un objet mock n’est pas possible, l’objet essaye d’avoir le comportement qui fera planter le moins possible :

>>> int(mock)
1
>>> [m for m in mock]
[]

On dirait que le bâton, là, c’est un sabre laser

Parfois, néanmoins, il est utile de vouloir avoir un comportement spécifique. Il se trouve que les méthodes des objets mocks peuvent être des objets mocks. Mock, mock, mock !!!!!

Et les objets mocks peuvent être configurés pour avoir un effet de bord ou une valeur de retour :

mock.you = MagicMock(side_effect=ValueError('mofo !')) # un callable marche aussi
>>> mock.you()
Traceback (most recent call last):
  File "<ipython-input-21-a7e6455585e9>", line 1, in <module>
    mock.you()
  File "/usr/lib/python3.4/unittest/mock.py", line 885, in __call__
    return _mock_self._mock_call(*args, **kwargs)
  File "/usr/lib/python3.4/unittest/mock.py", line 941, in _mock_call
    raise effect
ValueError: mofo !
>>> mock.mock = MagicMock(return_value="moooooooooock")
>>> mock.mock()
'moooooooooock'

Cela vous permet d’utiliser les objets mocks comme des remplacements pour des objets réels dans vos tests mais chiants à instancier comme une event loop, un serveur, une connexion à une base de données… Ca permet aussi de remplacer des appels très longs par des trucs instantanés.

Mais la partie vraiment fun, c’est qu’on peut associer des vrais objets avec des objets mocks :

>>> class VraiObjetSerieuxEtTout:
...     def faire_un_truc_super_serieux(self):
...         return "... and don't call me Shirley"
...     def faire_un_autre_truc_serieux(self):
...         return "why so serious ?"
...
>>> sirius = VraiObjetSerieuxEtTout()
>>> sirius.faire_un_truc_super_serieux = MagicMock() # It's a kinda magic, magic !
>>> sirius.faire_un_autre_truc_serieux()
'why so serious ?'
>>> sirius.faire_un_truc_super_serieux('ieux').delamort()[3:14] + [1, 2]
<MagicMock name='mock().delamort().__getitem__().__add__()' id='140302103288296'>

Et là ça devient super sympa : vous pouvez utilisez vos vrais objets, et pour certains appels, juste vous faciliter la vie pour les tests.

On dirait qu’on compte le nombre de balles que tu as tirées

Puisque les objets mocks sont un peu les grosses salopes de la programmation et acceptent tout ce qui vient (oups, je viens de tuer l’ambiance métaphore enfantine là), il peut être nécessaire de vérifier ce qui s’est passé. Or il se trouve qu’ils intègrent un historique des appels :

>>> sirius.faire_un_truc_super_serieux.mock_calls
[call('ieux'),
 call().delamort(),
 call().delamort().__getitem__(slice(3, 14, None)),
 call().delamort().__getitem__().__add__([1, 2])]

Et comme vérifier qu’un appel a bien eu lieu est une tâche courante, des méthodes pour les tests unitaires ont été intégrées :

>>> sirius.faire_un_truc_super_serieux.assert_called_with('ieux')
>>> sirius.faire_un_truc_super_serieux.assert_called_with('not_ieux')
Traceback (most recent call last):
  File "<ipython-input-56-e8c4890f08d9>", line 1, in <module>
    sirius.faire_un_truc_super_serieux.assert_called_with('not_ieux')
  File "/usr/lib/python3.4/unittest/mock.py", line 760, in assert_called_with
    raise AssertionError(_error_message()) from cause
AssertionError: Expected call: mock('not_ieux')
Actual call: mock('ieux')

On dirait que tu vas mettre ces porte-jartelles et…

Pour finir, le module mock vient avec patch(), qui sert à, surprise, patcher les objets, et propose des context managers et des décorateurs pour se faciliter la vie.

Par exemple, détourner open() temporairement :

>>> from unittest.mock import patch, mock_open
>>> with patch('__main__.open', mock_open(read_data='wololo'), create=True) as mock:
...     with open('zefile') as h:
...         result = h.read()
...
>>> mock.assert_called_once_with('zefile')
>>> assert result == 'wololo'

Ou alors avoir une partie d’un module qui soit un mock pour tout un appel de fonction :

@patch('os.listdir')
def ah(mock):
    import os
    print(os.listdir('.'))
    # l'objet mock initial est aussi passé en param automatiquement
    print(mock)
ah()
## <MagicMock name='listdir()' id='140302096346864'>
## <MagicMock name='listdir' id='140302101454688'>

Le module mock est vraiment très complet, avec des outils pour checker les signatures, passer isinstance(), overrider le contenu d’un dico, et tout un tas de cas particuliers et corner cases. Donc lisez la doc si vous rencontrez un blocage avant de paniquer.

Dis, comment on fait les bébés

Exemple prit d’une base de code IRL, avec une fonction pytest qui teste un objet response représentant une réponse HTTP. Si on appelle write() sur cet objet sous-jacent elle doit faire des appels à deux méthodes privées et une méthode d’un objet Twisted.

Problème, ces méthodes :

  • Supposent qu’une event loop est lancée.
  • Ecrivent sur le réseau.
  • Sont potentiellement appelées de manière asynchrone, en dehors de notre contrôle.
  • Ont des side effects donc on veut être certains qu’elles sont appelées, et avec les bons paramètres.

Du coup, on les remplace par des objets mocks, et yala :

def test_write(response):
    assert response.write != response._req.write
    response._disable_rendering = MagicMock(name='_disable_rendering')
    response._set_twisted_headers = MagicMock(name='_set_twisted_headers')
    response.write(b'test')
    response._set_twisted_headers.assert_called_once_with()
    response._disable_rendering.assert_called_once_with()
    assert response.write == response._req.write
    response._req.write.assert_called_once_with(b'test')

Et pourquoi ? Et pourquoi ? Et pourquoi ?

Prochaines étapes, savoir quand tester, et quoi tester, mais aussi comment rendre un code plus testable. Probablement la partie qui sera la plus difficile à écrire pour moi, car c’est assez subjectif. On parlera sans doute du code coverage, et je gage que je vais devoir créer un petit projet bidon pour tester tout ça, du genre un minifieur d’URL ou autre. Faudra voir l’inspiration.

12 thoughts on “Un gros guide bien gras sur les tests unitaires en Python, partie 5

  • Vayel

    Merci pour cet article !

    comment vous assurer que cet appel à bien eu lieu > comment vous assurer que cet appel a bien eu lieu

    et si le code n’est pas le votre > et si le code n’est pas le vôtre

    on, va jouer à faire semblant. > on va jouer à faire semblant.

    On dirait que moi je t’attaques > On dirait que moi je t’attaque

    Un objet mock est un callable, c’est à dire > Un objet mock est un callable, c’est-à-dire

    Et là ça devient super sympas > Et là ça devient super sympa

    On dirait qu’on compte le nombre de balles que tu as tiré > On dirait qu’on compte le nombre de balles que tu as tirées

    Et comme vérifier qu’un appel à bien eu lieu est une tache > Et comme vérifier qu’un appel a bien eu lieu est une tâche

    et des decorateurs pour se faciliter la vie. > et des décorateurs pour se faciliter la vie.

    Dis, comment on fait les bébé > Dis, comment on fait les bébés

    sur cet objet, sous jacent elle doit > sur cet objet sous-jacent, elle doit

  • Ryzz

    Il l’avait dit, il l’a fait ! \o/

    Bon, maintenant je vais lire…

    Merci d’avance Sam !

  • Freenuts

    Perso, les tests unitaires, ça m’a toujours gonflé et je trouve que ça sert à rien, mais je sais, je ne suis pas à la mode.

  • Sam Post author

    Oui, mais pas avant juin, comme tu vas le comprendre avec l’article d’aujourd’hui.

  • slras

    <troll> Pourquoi on peut pas cliquer sur grosse salope </troll>

    Super article merci !

  • Sam Post author

    C’est une erreur impardonnable qu’il nous faut corriger.

  • toub

    ca parait sympa j’vais jeter un coup d’oeil pour mes TU.

    Cela dit je suis en général assez méfiant sur l’usage des mocks dans les TU. Notamment je pense que vérifier l’usage qui a été fait du mock par la classe qu’on est en train de tester (ex compter le nombre d’appels de telle méthode du mock, vérifier les paramètres d’appels…) est un peu foireux, parce que cela revient à vérifier l’implémentation interne de l’objet qu’on teste, et non son comportement externe-API. Si l’algo interne change, les TU tombent, alors que son comportement externe est peut-etre toujours valide…

    Le projet sur lequel je suis en ce moment est plein de ces TU foireux, où finalement on ne vérifie que l’algorithme, pas le comportement externe – bon faut dire que l’API des objets qu’on teste est totalement merdique

    Tout ça pour conclure : pas de TU de qualité possibles, sans une API béton

  • Sam Post author

    C’est vrai, simplement généralement les mocks vont justement être utilisé quand on va vouloir tester des choses qui n’ont pas justement une API bêton. Si un API est bien faite, on a finalement très peu besoin de mock, car tout est testable et indépendant.

  • Patrick Deflandre

    Juste une remarque :

    il manque un from unittest.mock import mock_open dans l’exemple sur les porte-jartelles :)

    Sinon super série d’articles, merci :)

  • Zala

    Merci mille fois pour cette série d’articles : excellents, comme toujours !

    Peut-on espérer en voir la fin un jour ? Keep it up!

Comments are closed.

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.