Имитация возвращаемых значений в 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.