Юнит - тесты, использование mock и jsonschema
К коду необходимо писать тесты, функционал должен быть как можно лучше покрыт ими. Это аксиома, недаром существует 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, надеюсь, кому-то это будет полезно.