Имитация возвращаемых значений в Python
Модульные тесты проще писать, когда при заданном наборе входных данных вы знаете, что всегда получите ожидаемый ответ. Это становится немного сложнее, когда один и тот же набор входных данных может привести к разным ответам.
Когда вы работаете над программами, где иногда приходится делать запрос к внешнему API или обращаться к другим сервисам в ваших микросервисах, вы не можете быть уверены, что получите код состояния 200 на каждый запрос.
В основном вы будете принимать меры, чтобы справиться с неудачными запросами, но как написать модульные тесты, чтобы убедиться, что ваша система работает так, как ожидается?
Программа написания
Давайте создадим программу, которая пингует url и возвращает True, если код состояния равен 200, и False, если нет.
- Создайте файл
client.py - Напишите следующий код
# client.py
# Make sure you have requests installed.
# If not, you can install with `pip3 install requests`
# or `pip install requests`.
import requests
def ping(url):
res = requests.get(url)
retun res.status_code == 200
print(ping("https://google.com"))
Запустите python3 client.py в терминале в директории, где находится client.py. Ниже должен появиться ответ:
❯ python3 client.py
True
Письменные тесты
Наша программа работает, как и ожидалось. Пришло время написать тесты, чтобы убедиться, что мы возвращаем True, когда наш запрос равен 200, и False, когда код состояния не равен 200.
- Создайте файл
test_client.pyв той же директории, что иclient.py. - Напишите следующий код:
# test_client.py
import unittest
from client import ping
class TestClient(unittest.TestCase):
def setUp(self):
self.url = "https://google.com"
def test_ping_returns_200(self):
result = ping(self.url)
self.assertTrue(result)
if __name__ == "__main__":
unittest.main()
Запустите файл python3 test_client.py. Вы должны получить ответ, как показано ниже:
❯ python3 test_client.py
.
---------------------------------------------------------
Ran 1 test in 1.113s
OK
Все наши тесты пройдены, но теперь давайте проверим случай, когда код состояния не равен 200. Давайте добавим еще один тест к нашему тесту:
# test_client.py
import unittest
from client import ping
class TestClient(unittest.TestCase):
def setUp(self):
self.url = "https://google.com"
def test_ping_returns_200(self):
result = ping(self.url)
self.assertTrue(result)
# New
def test_ping_returns_500(self):
result = ping(self.url)
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()
При повторном запуске python3 test_client.py наш новый тест проваливается.
❯ python3 test_client.py
.F
==========================================================
FAIL: test_ping_returns_500 (__main__.TestClient.test_ping_returns_500)
----------------------------------------------------------
Traceback (most recent call last):
File "/Users/mock/test_client.py", line 46, in test_ping_returns_500
self.assertFalse(result)
AssertionError: True is not false
-----------------------------------------------------------
Ran 2 tests in 2.076s
FAILED (failures=1)
Это происходит потому, что сейчас наш запрос возвращает 200, поэтому мы не можем проверить, когда наш пинг не работает.
Чтобы сделать это, мы будем имитировать наши запросы и ответы. Это даст нам возможность определить, какой ответ мы должны получить.
Таким образом, мы можем манипулировать нашими запросами, чтобы они возвращали код состояния 500, а не реальный код состояния.
Имитация запросов и ответов
Сначала мы импортируем декоратор патчей (patch) и объект MagicMock из unittest.mock.
Декоратор патча (patch) позволяет нам редактировать наши запросы (requests), он принимает цель, которая будет запросами, которые мы хотим изменить, в данном случае это запрос в client.py.
Объект MagicMock позволяет нам создать нужный нам ответ, чтобы мы могли передать его в запросы.
Мы добавили дополнительный параметр, который можно назвать как угодно. Я назову его mock_requests. Это объект, к которому мы можем прикрепить наш ответ.
Давайте добавим эти изменения в наш test_client.py:
# test_client.py
import unittest
from unittest.mock import patch, MagicMock
from client import ping
class TestClient(unittest.TestCase):
def setUp(self):
self.url = "https://google.com"
def test_ping_returns_200(self):
result = ping(self.url)
self.assertTrue(result)
@patch("client.requests") # New
def test_ping_returns_500(self, mock_requests): # New
# New
mock_response = MagicMock()
mock_response.status_code = 500
mock_requests.get.return_value = mock_response
result = ping(self.url)
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()
Давайте посмотрим, что мы уже успели сделать!
- Добавляя декоратор
@patch("client.requests"), мы говорим, что хотим изменить функциюrequests, которую мы импортировали вclient.py. Это может быть любая функция, мы могли бы нацелиться на самping. После этого вы можете попробовать сделать это самостоятельно. Декоратор патча изменяет наш метод тестов и передает второй аргумент методуtest_ping_returns_500. Таким образом, мы добавляемmock_requestsв качестве параметра к нашему методу. Этотmock_requestsпредставляет собой имитированный вариантrequests. - Мы инстанцируем наш объект
MagicMockи назначаем егоmock_response. Наш объектMagicMockпозволяет нам принимать форму любого класса, который мы хотим, и в данном случае это объектResponse. - Мы добавляем атрибут
status_codeк нашемуmock_responseи присваиваем ему значение500. - Далее мы назначаем наш
mock_responseнаmock_requests.get.return_value. Теперь мы изменили наши запросы таким образом, что, когдаrequestsвclient.pyвызывают функциюget, мы хотим, чтобы возвращаемое значение было значением нашегоmock_response. Таким образом, когда вызываетсяrequests.get(), возвращаемое значение будетmock_response.
Если мы снова запустим наш тест, то увидим, что теперь все работает.
❯ python3 test_client.py
..
----------------------------------------------------------------------
Ran 2 tests in 1.609s
OK
Мы можем проделать то же самое для метода test_ping_returns_200, но сделать кодом статуса 200, чтобы мы могли добавить фальшивый url вместо запросов к https://google.com.
# test_client.py
import unittest
from unittest.mock import patch, MagicMock
from client import ping
class TestClient(unittest.TestCase):
def setUp(self):
self.url = "https://api.service.com" # New
@patch("client.requests") # New
def test_ping_returns_200(self, mock_requests): # New
# New
mock_response = MagicMock()
mock_response.status_code = 200
mock_requests.get.return_value = mock_response
result = ping(self.url)
self.assertTrue(result)
@patch("client.requests")
def test_ping_returns_500(self, mock_requests):
mock_response = MagicMock()
mock_response.status_code = 500
mock_requests.get.return_value = mock_response
result = ping(self.url)
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()
Мы можем снова запустить наши тесты, чтобы убедиться, что всё работает хорошо.
❯ python3 test_client.py
..
----------------------------------------------------------------------
Ran 2 tests in 1.609s
OK
Тело ответа для имитации
Мы также можем добавить тело ответа в наш mock_response. Предположим, что сторонний API, который мы пингуем, возвращает json-ответ с атрибутом {"status": "ok"}.
Давайте изменим нашу функцию в client.py, чтобы она тоже возвращала json-ответ, если код статуса равен 200, и None, если нет.
# client.py
import requests
def ping(url):
res = requests.get(url)
# New
if res.status_code == 200:
return (True, res.json())
return (False, None)
Изменим наш файл test_client.py следующим образом:
# test_client.py
import unittest
from unittest.mock import patch, MagicMock
from client import ping
class TestClient(unittest.TestCase):
def setUp(self):
self.url = "https://api.service.com"
@patch("client.requests")
def test_ping_returns_200(self, mock_requests):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"status": "ok"} # New
mock_requests.get.return_value = mock_response
status, body = ping(self.url) # New
self.assertTrue(status)
self.assertEqual(body["status"], "ok")
@patch("client.requests")
def test_ping_returns_500(self, mock_requests):
mock_response = MagicMock()
mock_response.status_code = 500
mock_requests.get.return_value = mock_response
status, body = ping(self.url) # New
self.assertFalse(status)
if __name__ == "__main__":
unittest.main()
Добавив mock_response.json.return_value, мы хотим сказать, что при вызове функции json на нашем mock_response мы должны получить {"status": "ok"}.
Таким образом, {"status": "ok"} - это значение, которое мы получим при вызове res.json() в client.py.
Заключение
Запросы с имитацией упрощают тестирование, особенно когда вам приходится иметь дело с API сторонних производителей и даже с другими сервисами в ваших микросервисах.
Допустим, у вас есть API, который сначала делает запрос к вашему auth-сервису, чтобы подтвердить наличие у пользователя определенных прав, прежде чем разрешить ему доступ к определенным ресурсам.
Вы можете сымитировать полученный ответ, чтобы проверить, что доступ к ресурсу разрешен только пользователям с необходимыми разрешениями.
Код для этого урока можно найти здесь: https://github.com/quamejnr/python-mock-requests.