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 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) :
- Dummy Object
- Test Stub
- Test Spy
- Mock Object
- 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 :
mock.patch()
(et d'autres patcheurs) permettent d'échanger temporairement des objets Python par des simulacresMock
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'unMagicMock()
- l'argument
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
:
- 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
- l'accès à un attribut inexistant échoue, c.f.
- 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
- c.f.
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.