Simulacres en Python avec Mock

La simulation (mocking) consiste à remplacer une partie d'un système informatique par un "simulacre" (mock) qui en imite le comportement.

Risques de la simulation

Les simulacres se comportent différemment des composants qu'ils remplacent.

Plus cette différence de comportement est grande, plus on risque de finir par tester le simulacre plutôt que le composant remplacé, plus les tests sont susceptibles d'être inexacts.

Or des tests inexacts donnent l'impression que le système fonctionne… alors qu'il ne fonctionne pas !

Types de simulacres

Le livre XUnit Test Patterns est l'un des premiers à avoir théorisé les simulacres.

Il en parle sous l'appellation Test Double et en distingue 5 types qui agissent sur le SUT (System Under Test) :

  1. Dummy Object
  2. Test Stub
  3. Test Spy
  4. Mock Object
  5. Fake Object

Les différences entre ces types peuvent prêter à confusion, c.f. What's the difference between faking, mocking, and stubbing?.

unittest.mock

En Python, on parle de mock object pour désigner un objet simulacre.

La bibliothèque standard unittest.mock permet deux choses :

  1. mock.patch() (et d'autres patcheurs) permettent d'échanger temporairement des objets Python par des simulacres
  2. Mock et ses sous-classes (MagicMock, PropertyMock, etc.) permettent de construire des simulacres flexibles

Patcheurs

Pour utiliser un patcheur, on cible un objet :

  • soit par référence directe
  • soit par la chaîne de caractères du chemin de la cible

mock.patch()

Le patcheur le plus utilisé est mock.patch() qui prend en argument la chaîne de caractères du chemin de la cible :

  • la chaîne est utilisée pour importer l'objet
  • l'attribut nommé est remplacé par un objet MagicMock() par défaut
    • l'argument new permet de retourner un objet personnalisé "fait-main" à la place d'un MagicMock()

Le ciblage est délicat

Le ciblage est délicat car il faut utiliser l'endroit où la cible est utilisée, pas l'endroit où elle est définie.

Et cela dépend de la façon dont l'instruction import est utilisée en amont.

Voir à ce sujet :

Simulacres

Mock()

La classe Mock permet de remplacer presque tout sur un simulacre :

In [1]: from unittest import mock
In [2]: mocked_object = mock.Mock()
In [3]: mocked_object
Out[3]: <Mock id='4342689136'>
In [4]: mocked_object.size
Out[4]: <Mock name='mock.size' id='4343702976'>

Elle a un comportement "attrape-tout" : ici, l'attribut size a été créé à la volée.

Ses valeurs de retour peuvent être configurées à l'envie :

In [1]: from unittest import mock
In [2]: mocked_object = mock.Mock()
In [3]: mocked_object.return_value.foo.bar.baz = "qux"
In [4]: assert mocked_object().foo.bar.baz == "qux"

Par ailleurs, un objet Mock() :

  • est appelable (callable)
  • accepte n'importe quel argument
  • retourne d'autres Mock()s
  • possède des attributs et méthodes permettant de tester les actions auxquelles il participe :
    • assert_called
    • assert_called_with
    • mock_calls
    • etc.

Danger du comportement "attrape-tout"

Ce comportement "attrape-tout" rend possible la bonne exécution d'un test qui devrait échouer.

En effet, presque toutes les opérations sur un Mock() réussissent et une typo évidente sur un objet simulacre, comme .ojets à la place de .objects, ne ferait pas échouer le test.

Il est possible de sécuriser ce comportement en utilisant des spécifications.

Spécifications

Il existe deux types de spécifications (ou "specs") qui permettent de définir les attributs d'un simulacre pour les faire correspondre à ceux de l'objet cible :

  • les "specs" ordinaires (spec)
  • les "autospecs" (autospec)

L'argument spec de la classe Mock() peut être :

  • une liste de noms d'attributs
  • un objet à partir duquel copier les noms d'attributs

Par exemple, pour restreindre les attributs à ceux définis sur la classe User :

In [1]: from unittest.mock import Mock
In [2]: from django.contrib.auth.models import User

In [3]: mocked_user = Mock(spec=User)

In [4]: mocked_user.objects
Out[4]: <Mock name='mock.objects' id='11233802704'>

In [5]: mocked_user.foo
AttributeError: Mock object has no attribute 'foo'

In [6]: mocked_user.objects.get
Out[6]: <Mock name='mock.objects.get' id='11232875168'>

In [7]: mocked_user.objects.foo
Out[7]: <Mock name='mock.objects.foo' id='11232873872'>

On voit ici que spec :

  1. respecte la spécification
    • l'accès à un attribut inexistant échoue, c.f. mocked_user.foo
    • une faute de frappe User.ojets ou un changement d'API serait maintenant détecté dans les tests
  2. mais que la spécification n'affecte pas les enfants du simulacre
    • c.f. mocked_user.objects.foo
    • accéder à un attribut ou une méthode inconnue d'un enfant réussi toujours silencieusement

autospec est basé sur spec et s'utilise soit en tant qu'argument de patch(autospec=True), soit via create_autospec().

Il a le même comportement que spec sauf qu'il est récursif, c'est-à-dire qu'il prend en compte les enfants du simulacre :

In [1]: from unittest.mock import create_autospec
In [2]: from django.contrib.auth.models import User

In [3]: mocked_user = create_autospec(User)

In [4]: mocked_user.objects
Out[4]: <django.contrib.auth.models.UserManager at 0x2aa64b100>

In [5]: mocked_user.foo
AttributeError: Mock object has no attribute 'foo'

In [6]: mocked_user.objects.get
Out[6]: <MagicMock name='mock.objects.get' spec='method' id='11432032144'>

In [7]: mocked_user.objects.foo
AttributeError: Mock object has no attribute 'foo'

Les "autospecs" permettent de détecter beaucoup plus d'erreurs mais ne sont pas parfaites.

Par exemple, elles ne découvrent pas automatiquement les types de retour des fonctions, et renvoient à la place des objets Mock() génériques.

Sources

Avant L'encodage Base64 Après Aperçu des fonctions de confidentialité des navigateurs

Tag Kemar Joint