Юнит - тесты, использование mock и jsonschema | Блог python программиста
Изображение гика

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

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

6 октября 2018 г.

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

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

|- get_data.py
|- tests.py

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

1import requests as rq
2
3
4def 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 эта функция тестируется, допустим так:

 1import unittest
 2from get_data import get_post
 3
 4
 5class 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
13unittest.main()

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

python3 tests.py

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

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

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

 1import unittest
 2from get_data import get_post
 3from unittest.mock import patch, Mock
 4
 5
 6class 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
26unittest.main()

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

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

pip install jsonschema

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

 1import unittest
 2from get_data import get_post
 3from unittest.mock import patch, Mock
 4from jsonschema import validate
 5
 6
 7class 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
40unittest.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
Если вам понравился пост, можете поделиться им в соцсетях: