Изображение гика

Блог питониста

Юнит - тесты, использование mock и jsonschema

6 октября 2018 г.

К коду необходимо писать тесты, функционал должен быть как можно лучше покрыт ими. Это аксиома, недаром существует TDD (Test Driven Development), при котором вы сначала пишете тесты, а только потом функционал. В этом посте я попытаюсь осветить использование замечательных библиотек unittest.mock и jsonschema, которые могут облегчить тестирование, например, вашего API.

Например у вас есть два файла:

|- get_data.py
|- tests.py

В get_data.py используется какое-то стороннее API для получения данных:

1 import requests as rq
2 
3 
4 def get_post():
5     resp = rq.get('https://jsonplaceholder.typicode.com/posts/1')
6     if resp.status_code == 200:
7         return resp.json()
8     raise Exception

А в test.py эта функция тестируется, допустим так:

 1 import unittest
 2 from get_data import get_post
 3 
 4 
 5 class TestGetData(unittest.TestCase):
 6 
 7     def test_get_post(self):
 8         resp = get_post()
 9         self.assertIsInstance(resp, dict)
10         self.assertTrue(resp)
11 
12 
13 unittest.main()

Чтобы запустить тест нужно просто исполнить файл tests.py:

python3 tests.py

У этого подхода есть как минимум один существенный недостаток - тест зависит от внешнего API, которое, вообще говоря, может и не работать. В этом случае наш тест перестанет проходить. И это очень нехорошо. Что же делать? Можно использовать mock, чтобы "подменить" часть кода, которая взаимодействует с API, get_data.py:

 1 import requests as rq
 2 
 3 
 4 def make_request():
 5     return rq.get('https://jsonplaceholder.typicode.com/posts/1')
 6 
 7 
 8 def get_post():
 9     resp = make_request()
10     if resp.status_code == 200:
11         return resp.json()
12     raise Exception
13 
14 
15 get_post()

Я вынес код, вызывающий API в отдельную функцию, чтобы я мог ее мокнуть, tests.py:

 1 import unittest
 2 from get_data import get_post
 3 from unittest.mock import patch, Mock
 4 
 5 
 6 class TestGetData(unittest.TestCase):
 7 
 8     @patch('get_data.make_request')
 9     def test_get_post(self, mock_make_request):
10         mock_status_200 = Mock(status_code=200)
11         body = "test_body"
12         mock_status_200.json.return_value = {
13             "userId": 1,
14             "id": 1,
15             "title": "testtitle",
16             "body": body
17         }
18         mock_make_request.return_value = mock_status_200
19 
20         resp = get_post()
21         self.assertIsInstance(resp, dict)
22         self.assertTrue(resp)
23         self.assertEqual(resp["body"], body)
24 
25 
26 unittest.main()

То есть я сначала использую декоратор patch, чтобы получить доступ к заменяемой функции, затем создаю объект - пустышку Mock, который, тем не менее, имеет атрибут status_code, равный 200, и имеет функцию json, которая возвращает словарь. И затем я делаю так, чтобы функция make_request возвращала созданный Mock. Таким образом, я могу быть уверен, что мой тест будет работать независимо от внешнего API, однако, проблема есть в том, что мне, возможно, придется переписать тест, если внешнее API изменится. Но зато, я могу быть уверен, что тест не сломается.

Еще одна проблема, связанная с кодом в tests.py, заключается в том, что я не проверяю всю json структуру целиком, а только одно отдельное поле - body. Однако, есть способ протестировать весь json целиком используя jsonschema. Сначала установим эту библиотеку:

pip install jsonschema

Далее я распишу требуемую схему и буду валидировать ответ, tests.py:

 1 import unittest
 2 from get_data import get_post
 3 from unittest.mock import patch, Mock
 4 from jsonschema import validate
 5 
 6 
 7 class TestGetData(unittest.TestCase):
 8 
 9     def setUp(self):
10         self.valid_schema = {
11             "type": "object",
12             "properties": {
13                 "userId": {"type": "number"},
14                 "id": {"type": "number"},
15                 "title": {"type": "string"},
16                 "body": {"type": "string"}
17             },
18             "required": ["userId", "id", "title", "body"]
19         }
20 
21     @patch('get_data.make_request')
22     def test_get_post(self, mock_make_request):
23         mock_status_200 = Mock(status_code=200)
24         body = "test_body"
25         mock_status_200.json.return_value = {
26             "userId": 1,
27             "id": 1,
28             "title": "testtitle",
29             "body": body
30         }
31         mock_make_request.return_value = mock_status_200
32 
33         resp = get_post()
34         self.assertIsInstance(resp, dict)
35         self.assertTrue(resp)
36         self.assertEqual(resp["body"], body)
37         validate(resp, self.valid_schema)
38 
39 
40 unittest.main()

Обратите внимание на строчку 18 - "required": ["userId", "id", "title", "body"], она очень важна, если ее убрать, то эти поля не будут считаться обязательными в схеме, и если какого-то из них не будет, то ничего не произойдет, что недопустимо, ведь в этом случае тест пройдет. Так что, эта строка необходима, чтобы перечисленные поля считались обязательными, если вы закомментируете, к примеру строку 26, убрав из словаря поля userId, то при проверке схемы будет ошибка:

 1 ...
 2     def test_get_post(self, mock_make_request):
 3         mock_status_200 = Mock(status_code=200)
 4         body = "test_body"
 5         mock_status_200.json.return_value = {
 6             # "userId": 1,
 7             "id": 1,
 8             "title": "testtitle",
 9             "body": body
10         }
11 ...

python3 test.py

jsonschema.exceptions.ValidationError: 'userId' is a required property

Заключение


В данной статье я попытался описать то, как я тестирую функционал, взаимодействующий с API, надеюсь, кому-то это будет полезно.

Метки

python
Если вам понравился пост, можете поделиться им в соцсетях: