Skip to content

Commit b2540cd

Browse files
authored
Merge pull request #8 from JN-Lab/article-mock-unittest
publication article: les mocks avec unittest
2 parents 2b0fc80 + 49cf1cc commit b2540cd

1 file changed

Lines changed: 373 additions & 0 deletions

File tree

content/articles/mock_unittest.md

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
Title: Les mocks avec unittest
2+
Date: 2018-07-14 14:00
3+
Category: Articles
4+
Slug: les-mocks-avec-unittest
5+
Author: Julien Nuellas
6+
7+
# Les Mocks avec Unittest
8+
9+
Malgré les blagues et les débats que l'on peut lire concernant les tests unitaires (si vous ne les avez pas encore vu, voici quelques [exemples](https://twitter.com/thepracticaldev/status/687672086152753152) qui donne le [sourire](https://twitter.com/MonkeyTestIt/status/958661917375172609)), les test unitaires sont largement utilisés et s'intègrent directement dans l'approche [TDD](https://fr.wikipedia.org/wiki/Test_driven_development).
10+
11+
Les test unitaires servent donc à tester un bout de son code de façon isolée et vérifier que ce dernier fasse bien le travail qui lui a été demandé.
12+
Prenons l'exemple suivant, je dois créer un algorithme qui reçoit en entrée une liste de tuples composée d'un prénom, d'un âge et d'une taille et je dois le retourner sous forme d'un dictionnaire.
13+
14+
Pour tester ce code, je vais donc dans la réalisation de mon test simuler une fausse entrée (une liste de tuple) et indiquer le résultat attendu (le dictionnaire que je souhaiterais renvoyer). Je vais appliquer mon algorithme avec cette fausse entrée et comparer la sortie avec le résultat attendu. Si c'est pareil, le test est validé et si c'est pas pareil, faut retourner travailler.
15+
C'est logique, c'est propre, c'est net. Malheureusement que faire si l'entrée en question provient d'une source externe comme une API par exemple ? Dans ce cas-là, on va avoir un peu plus de difficulté à simuler l'entrée. Autre point, comment fait-on si la sortie de l'algorithme consiste à envoyer un mail à l'utilisateur ? Pas évident non plus de comparer les résultats attendus.
16+
17+
Mais ne vous inquiétez pas pour autant, car vous n'êtes pas le premier à être confronté à ce type de problématiques et des solutions ont été trouvées pour y répondre. Et c'est justement l'objectif de cet article qui va vous présenter la magie des Mocks !
18+
19+
## Qu'est-ce qu'un Mock ?
20+
21+
Un Mock, comme son nom l'indique (et oui c'est de l'anglais) est un objet qui consiste à imiter un autre objet.
22+
Conceptuel n'est-ce pas ? Tout simplement, un mock c'est ce qui va vous permettre de simuler un retour API sans vraiment appeler une API ou simuler un envoi d'email sans vraiment envoyer un email. Cela permet d'imiter pas mal de choses afin de vous permettre de réaliser vos tests unitaires de façon indépendante.
23+
24+
## Dans la pratique, ça donne quoi ?
25+
26+
Rien de mieux qu'un exemple pour appréhender un peu mieux ce concept.
27+
Imaginons le besoin suivant : je développe un site qui a pour objectif d'identifier des produits de substitution meilleur pour la santé par rapport à un produit donné. Pour cela, je souhaite interroger via l'[API OpenFoodFacts](https://fr.openfoodfacts.org/data) les produits liés à une marque et compter le nombre de produits ayant une bonne note alimentaire.
28+
Voici le code de ma classe (fichier app.py) :
29+
30+
```python
31+
import urllib.error
32+
import json
33+
34+
class OpenFoodFactsAPI:
35+
"""
36+
Cette classe a pour objectif de récupérer les produits associée à une marque
37+
via l'API d'OpenFoodFacts et de compter le nombre de produits ayant une bonne
38+
note alimentaire.
39+
"""
40+
41+
def _get_product_from_api(self, brand):
42+
"""
43+
Cette méthode a pour objectif de récupérer les 150 premiers produits liés
44+
à une marque via l'API OpenFoodFacts
45+
"""
46+
47+
payload = {
48+
'action' : 'process',
49+
'json' : '1',
50+
'tagtype_0' : 'brands',
51+
'tag_contains_0' : 'contains',
52+
'tag_0' : brand,
53+
'page_size' : '150',
54+
'page' : '1'
55+
}
56+
57+
parameters = urllib.parse.urlencode(payload)
58+
url = "http://fr.openfoodfacts.org/cgi/search.pl"
59+
parameters = parameters.encode('utf-8')
60+
req = urllib.request.Request(url, parameters)
61+
62+
try:
63+
response = urllib.request.urlopen(req)
64+
response_body = response.read().decode("utf-8")
65+
data = json.loads(response_body)
66+
return data
67+
except urllib.error.HTTPError as e:
68+
print('The server couldn\'t fulfill the request.')
69+
print('Error code: ', e.code)
70+
except urllib.error.URLError as e:
71+
print('We failed to reach a server.')
72+
print('Reason: ', e.reason)
73+
74+
def count_product_numb(self, brand):
75+
"""
76+
Cette méthode a pour objectif de dénombrer le nombre de produits ayant
77+
une bonne note alimentaire
78+
Il s'agit de la méthode à tester pour cette exercice
79+
"""
80+
81+
data = self._get_product_from_api(brand)
82+
healthy_product = 0
83+
for product in data["products"]:
84+
try:
85+
if product["nutrition_grade_fr"] == "a":
86+
healthy_product += 1
87+
except:
88+
pass
89+
90+
return healthy_product
91+
```
92+
93+
### Objectif de mon test
94+
95+
Je souhaite tester ma méthode **count_product_numb()** afin de m'assurer que cette dernière me renvoie bien le bon nombre de produits ayant une bonne note alimentaire.
96+
Dans notre cas, l'algorithme est censé travailler sur la donnée provenant de l'API récupérée via la méthode **_get_product_from_api()**. Hors, j'y vois deux problèmes pour mon test :
97+
* D'un point de vue performance, si j'ai 150 tests qui font tous des appels externes, je ne suis pas prêt de visualiser les résultats de ces derniers.
98+
* Je n'ai aucune idée du nombre de produits (dont des produits avec une bonne note alimentaire) que l'API va me renvoyer. Je pourrais bien évidemment faire le test à coté et les compter mais qui me dit que dans le temps, des produits ne seront pas rajouté ou supprimé ?
99+
100+
Du coup, pour tester ma méthode, il faudrait que je puisse simuler ce retour d'API pour avoir une donnée similaire à celle-ci.
101+
Et bien, c'est tout l'intérêt des mocks justement.
102+
103+
### Mise en place de mon test
104+
105+
Pour mettre en place ce test, je vais utiliser le module unittest. L'avantage de ce module, c'est qu'il est directement intégré dans Python et qu'il dispose de la possibilité de mettre en place des mocks, et même de plusieurs façons différentes !
106+
107+
Je mets donc en place la structure de test suivante (fichier test_app.py) :
108+
109+
```python
110+
from app import OpenFoodFactsAPI
111+
from unittest import TestCase
112+
113+
class TestOpenFoodFactsAPI(TestCase):
114+
115+
def test_count_product_numb(self):
116+
"""
117+
La méthode qui doit tester le fonctionnement de ma méthode
118+
count_product_numb()
119+
"""
120+
121+
test_api = OpenFoodFactsAPI()
122+
result = 2
123+
self.assertEqual(self.test_api.count_product_numb('nutella'), result)
124+
125+
```
126+
127+
Il y déja quelques informations avec la structure de test présentée au-dessus. En effet, que se passe-t-il déjà ?
128+
1. On importe dans une variable locale la classe **OpenFoodFactsAPI** afin de pouvoir l'utiliser dans notre test
129+
2. On importe dans une variable locale la classe **TestCase** du module unittest dont notre classe de test va hériter afin de pouvoir notamment utiliser les méthodes de comparaison
130+
3. On définit une méthode de test où :
131+
* on instancie un objet de la classe OpenFoodFactsAPI
132+
* on définie un résultat (ici 2)
133+
* on utilise la méthode assertEqual de la classe TestCase pour comparer le résultat renvoyé par la méthode **count_product_numb()** avec le résultat que l'on attend
134+
135+
Ne criez pas au scandale ! J'entend d'ici votre question. Pourquoi 2 comme résultat ? Comment sait-on que l'API va nous renvoyer deux produits avec une bonne note alimentaire ?
136+
Et bien oui, on ne le sait pas... Vous voyez un peu l'impasse ? Pourtant, cette méthode doit absolument être testée car ce qu'elle renvoie sera utilisée à l'extérieur de la classe et il est donc important que cette dernière fasse bien le travail qui lui a été demandé.
137+
138+
Comment faire alors ?... Il faut que je puisse tester cette méthode en lui donnant en entrée un json similaire à ce qui est renvoyé par la méthode **_get_product_from_api()** comportant donc 2 produits avec une bonne note alimentaire. (J'aurais bien évidemment pu en choisir 3, 5 ou 10000). Voyons dans la suite de l'article comment faire.
139+
140+
### La solution sans utilisation d'un mock
141+
142+
Comment ??? On parle d'un sujet sur les mocks et on propose une solution sans mock ? Oui, je l'admets, c'est un peu culotté de ma part mais je pense que ce n'est pas inutile de la décrire car ça aide à comprendre un peu la logique de fonctionnement.
143+
144+
Regardons déjà la solution :
145+
146+
```python
147+
from app import OpenFoodFactsAPI
148+
from unittest import TestCase
149+
150+
class TestOpenFoodFactsAPI(TestCase):
151+
152+
def test_count_product_numb(self):
153+
154+
# On instancie un object de la classe OpenFoodFactsAPI
155+
healthy_product = OpenFoodFactsAPI()
156+
157+
# On crée une méthode au sein de la méthode de test
158+
# Il définit juste un exemple de retour de la méthode _get_product_from_api()
159+
def fake_api_result(self):
160+
result = {
161+
"count": 6,
162+
"skip": 0,
163+
"page_size": "150",
164+
"page": 1,
165+
"products": [
166+
{
167+
"product_name_fr" : "Ferrero boite de 30",
168+
"nutrition_grade_fr": "a",
169+
},
170+
{
171+
"product_name_fr" : "Ferrero Light sans sucre et sans goût",
172+
"nutrition_grade_fr": "b",
173+
},
174+
{
175+
"product_name_fr" : "Ferrero Rocher",
176+
"nutrition_grade_fr": "e",
177+
},
178+
{
179+
"product_name_fr" : "Ferrero couscous",
180+
"nutrition_grade_fr": "a",
181+
},
182+
{
183+
"product_name_fr" : "Ferrero chocolat praliné",
184+
"nutrition_grade_fr": "d",
185+
},
186+
{
187+
"product_name_fr" : "Ferrero à la fraise",
188+
"nutrition_grade_fr": "c",
189+
},
190+
]
191+
}
192+
return result
193+
194+
# On assigne ensuite ce retour à la méthode ciblée
195+
healthy_product._get_product_from_api = fake_api_result
196+
197+
# On fait le test de comparaison
198+
self.assertEqual(healthy_product.count_product_numb("ferrero"), 2)
199+
```
200+
201+
Et voilà le travail ! On utilise ici le caractère dynamique de Python ainsi que ces règle de portées de variables pour "forcer" le retour de la méthode **_get_product_from_api**. De cette façon, la méthode **count_product_numb()** va utiliser le dictionnaire défini et retourné par la méthode **fake_api_result()** pour compter le nombre de produit ayant une bonne note.
202+
203+
Alors c'est super et ça fonctionne mais imaginons maintenant que l'on ait à mocker plusieurs éléments, cela risque de rendre le code difficilement lisible et il doit y avoir une méthode plus sympa que de créer des méthodes imbriquées.
204+
Et bien oui, c'est le cas. Voyons maintenant deux autres façons de faire en utilisant l'object **Mock** du module unittest, puis de son décorateur **patch**.
205+
206+
### Utilisation de la classe Mock
207+
208+
Unittest propose une classe Mock permettant de "mocker" facilement une classe, un objet ou une méthode. Cela permet d'indiquer au processus de test que cet objet est une imitation et on va pouvoir agir dessus par l'intermédiaire des méthodes de la classe Mock (comme par exemple lui retourner une valeur !).
209+
210+
Voyons ce que cela donne :
211+
212+
```python
213+
from app import OpenFoodFactsAPI
214+
from unittest import TestCase
215+
from unittest.mock import Mock
216+
217+
class TestOpenFoodFactsAPI(TestCase):
218+
219+
def test_count_product_numb(self):
220+
221+
api_response = {
222+
"count": 6,
223+
"skip": 0,
224+
"page_size": "150",
225+
"page": 1,
226+
"products": [
227+
{
228+
"product_name_fr" : "Ferrero boite de 30",
229+
"nutrition_grade_fr": "a",
230+
},
231+
{
232+
"product_name_fr" : "Ferrero Light sans sucre et sans goût",
233+
"nutrition_grade_fr": "b",
234+
},
235+
{
236+
"product_name_fr" : "Ferrero Rocher",
237+
"nutrition_grade_fr": "e",
238+
},
239+
{
240+
"product_name_fr" : "Ferrero couscous",
241+
"nutrition_grade_fr": "a",
242+
},
243+
{
244+
"product_name_fr" : "Ferrero chocolat praliné",
245+
"nutrition_grade_fr": "d",
246+
},
247+
{
248+
"product_name_fr" : "Ferrero à la fraise",
249+
"nutrition_grade_fr": "c",
250+
},
251+
]
252+
}
253+
254+
healthy_product = OpenFoodFactsAPI()
255+
healthy_product._get_product_from_api = Mock()
256+
healthy_product._get_product_from_api.return_value = api_response
257+
258+
self.assertEqual(healthy_product.count_product_numb("ferrero"), 2)
259+
```
260+
261+
Il y a plusieurs étapes derrière ce code :
262+
1. On importe tout d'abord la classe Mock du module unittest
263+
2. On définit la valeur de retour de l'API représentée ici par la variable api_response
264+
3. On mock la méthode get_product_from_api de l'objet healthy_product
265+
4. On lui associe la valeur de retour via la méthode return.value
266+
5. On utilise la méthode assertEqual() de la classe TestCase afin de comparer le retour de la méthode que l'on teste avec le résultat attendu
267+
268+
A noter qu'il est possible de fusionner le point 3 et 4 en une seule fois en utilisant l'argument return_value lorsque la méthode est mockée :
269+
270+
```python
271+
healthy_product._get_product_from_api = Mock(return_value=api_response)
272+
```
273+
274+
### Utilisation du décorateur patch
275+
276+
Une des autres façons de mettre en place un mock avec unittest est d'utiliser son décorateur patch.
277+
Ce décorateur permet - comme son nom l'indique - de 'patcher' un objet uniquement au sein de la fonction à laquelle elle est appelée. En effet, cela gère automatiquement le 'dé-patching' même si des exceptions sont levées.
278+
279+
Voyons un peu ce que cela donne :
280+
281+
```python
282+
from app import OpenFoodFactsAPI
283+
from unittest import TestCase
284+
from unittest.mock import patch
285+
286+
class TestOpenFoodFactsAPI(TestCase):
287+
288+
@patch('app.OpenFoodFactsAPI._get_product_from_api')
289+
def test_count_product_numb(self, mock_get_product_from_api):
290+
291+
mock_get_product_from_api.return_value = {
292+
"count": 6,
293+
"skip": 0,
294+
"page_size": "150",
295+
"page": 1,
296+
"products": [
297+
{
298+
"product_name_fr" : "Ferrero boite de 30",
299+
"nutrition_grade_fr": "a",
300+
},
301+
{
302+
"product_name_fr" : "Ferrero Light sans sucre et sans goût",
303+
"nutrition_grade_fr": "b",
304+
},
305+
{
306+
"product_name_fr" : "Ferrero Rocher",
307+
"nutrition_grade_fr": "e",
308+
},
309+
{
310+
"product_name_fr" : "Ferrero couscous",
311+
"nutrition_grade_fr": "a",
312+
},
313+
{
314+
"product_name_fr" : "Ferrero chocolat praliné",
315+
"nutrition_grade_fr": "d",
316+
},
317+
{
318+
"product_name_fr" : "Ferrero à la fraise",
319+
"nutrition_grade_fr": "c",
320+
},
321+
]
322+
}
323+
324+
healthy_product = OpenFoodFactsAPI()
325+
self.assertEqual(healthy_product.count_product_numb("ferrero"), 2)
326+
```
327+
328+
Décrivons également les différentes étapes :
329+
1. On importe le décorateur patch du module unittest
330+
2. On met en place le décorateur qui prend en argument l'objet à mocker
331+
3. Le décorateur injecte l'objet mocker au sein de la fonction comme un argument de la méthode. Le nom de l'argument est libre de choix. Ici, il s'agit de l'argument **mock_get_product_from_api**.
332+
4. On utilise la méthode return_value pour associer la valeur de retour souhaitée au mock.
333+
5. On instancie un objet via la classe OpenFoodFactsAPI()
334+
6. On utilise la méthode assertEqual de la classe TestCase pour comparer la valeur renvoyée par la méthode que l'on teste avec le résultat attendu
335+
336+
Petite remarque supplémentaire, attention à ne patcher uniquement que la méthode que l'on souhaite mocker et non la classe entière.
337+
En effet, en faisant comme ceci :
338+
339+
```python
340+
...
341+
class TestOpenFoodFactsAPI(TestCase):
342+
343+
@patch('app.OpenFoodFactsAPI')
344+
def test_count_product_numb(self, mock_OpenFoodFactsAPI):
345+
mock_OpenFoodFactsAPI._get_product_from_api.return_value = {
346+
"count": 6,
347+
"skip": 0,
348+
"page_size": "150",
349+
...
350+
```
351+
352+
Le mock ne fonctionnera pas. En effet, unittest va créer un grand objet mock sur l'ensemble de la classe et ne dissociera pas la partie de la classe qui doit être imitée et celle qui ne le doit pas.
353+
Du coup, lors du lancement du test, l'appel à l'API sera réalisé et la méthode **count_product_numb** sera appliquée sur la donnée renvoyée par l'API et non celle que l'on a configurée.
354+
355+
Attention donc à bien mocker le périmètre que l'on souhaite imiter.
356+
357+
## Quelle solution choisir au final ?
358+
359+
Et bien, de mon côté, j'ai une préférence pour l'utilisation du décorateur **patch** car je trouve que le code est plus lisible et cela m'assure surtout que le mock ne sera actif que durant le test de ma méthode car comme indiqué plus haut, il gère automatiquement la destruction du mock à la fin du test.
360+
En revanche, l'utilisation de la classe Mock n'est pas dénué d'intérêt lorsque plusieurs tests (donc plusieurs méthodes) doivent faire appel au même objet à mocker. J'aurais plus tendance à utiliser cette solution dans ce type de cas en définissant mon objet mockée dans une méthode *SetUp*.
361+
362+
Mais au final, il n'appartient qu'à vous de choisir. Les goûts et les couleurs, ça ne se discute pas !
363+
364+
## Pour faire quelques tests
365+
366+
Si vous souhaitez faire quelques tests sur l'exemple de l'article, vous pouvez retrouver le code source à cette adresse :
367+
<https://github.com/JN-Lab/Test-Mock-Unittest>
368+
369+
Il y a différents fichiers de tests avec différentes méthodes appliquées dont une qui ne fonctionnent pas et qui faitréférence au danger expliqué plus haut.
370+
371+
J'espère en tout cas que cet article vous aura permis d'y voir un peu plus clair sur la façon de mettre en place des mocks avec le module unittest.
372+
373+
Bon codage à tous !

0 commit comments

Comments
 (0)