[Перевод] Как улучшить тестируемость кода на примере внедрения зависимостей в Python
Несколько недель назад я наткнулся на эту замечательную лекцию Брэндона Родса. Один из ключевых выводов, который я сделал — это важность отделения операций ввода-вывода (т.е. сетевых запросов, обращений к базе данных и т.д.) от основной логики нашего кода. Это позволяет сделать наш код более модульным и тестируемым.
В этой статье я не буду углубляться в тонкости «чистой архитектуры» и я хочу сосредоточиться на том, что вы можете использовать немедленно. Давайте сразу перейдем к примеру!
readme
Это перевод, написано от лица автора. Если вы нашли ошибку — используйте, пожалуйста, Ctrl+Enter и я исправлю. Спасибо!
Функция find_definition
Представьте, что у нас есть функция find_definition
, которая выполняет обработку данных и включает в себя HTTP-запросы к внешнему API.
import requests # Listing 1
from urllib.parse import urlencode
def find_definition(word):
q = 'define ' + word
url = 'http://api.duckduckgo.com/?'
url += urlencode({'q': q, 'format': 'json'})
response = requests.get(url) # I/O
data = response.json() # I/O
definition = data[u'Definition']
if definition == u'':
raise ValueError('that is not a word')
return definition
Напишем первый тест
Для написания модульного теста для функции find_definition
мы используем модуль unittest. Пример:
import unittest
from unittest.mock import patch
class TestFindDefinition(unittest.TestCase):
@patch('requests.get')
def test_find_definition(self, mock_get):
mock_response = {u'Definition': 'Visit habr.com'}
mock_get.return_value.json.return_value = mock_response
expected_definition = 'Visit habr.com'
definition = find_definition('habr')
self.assertEqual(definition, expected_definition)
mock_get.assert_called_with('http://api.duckduckgo.com/?q=define+habr&format=json')
Чтобы изолировать операции ввода-вывода, мы используем декоратор patch из модуля unittest.mock. Он позволяет нам имитировать поведение функции requests.get. Таким образом, мы можем контролировать ответ, который получает наша функция во время тестирования и мы можем изолированно тестировать функцию find_definition
, не выполняя реальных HTTP-запросов.
Трудности тестирования и тесная связь
Используя декоратор patch для имитации поведения функции requests.get,
мы жестко привязываем тесты к внутренней работе функции. Это делает тесты более восприимчивыми к поломкам при изменениях в реализации или зависимостях.
Если реализация find_definition изменится, например в случаях:
использование другой библиотеки HTTP
изменение структуры ответа API
изменение конечной точки API
Возможно, потребуется соответствующее обновление тестов. В случае find_definition
написание и сопровождение модульных тестов превращается в громоздкую задачу!
Скрытие ввода-вывода: Распространенная ошибка
Обычно при работе с функциями типа find_definition
, включающими операции ввода-вывода, я часто провожу рефакторинг кода, чтобы вынести операции ввода-вывода в отдельную функцию, например call_json_api
, как показано в обновленном коде ниже:
def find_definition(word): # Listing 2
q = 'define ' + word
url = 'http://api.duckduckgo.com/?'
url += urlencode({'q': q, 'format': 'json'})
data = call_json_api(url)
definition = data[u'Definition']
if definition == u'':
raise ValueError('that is not a word')
return definition
def call_json_api(url):
response = requests.get(url) # I/O
data = response.json() # I/O
return data
Вынося операции ввода-вывода в отдельную функцию, мы достигаем определенного уровня абстракции и инкапсуляции. Теперь функция find_definition
делегирует функции call_json_api
ответственность за выполнение HTTP-запроса и разбор JSON-ответа.
Обновление теста
И снова мы используем декоратор patch из модуля unittest.mock, чтобы сымитировать поведение функции call_json_api
(вместо requests.get
). Таким образом, мы можем контролировать ответ, который получает find_definition
во время тестирования.
import unittest
from unittest.mock import patch
class TestFindDefinition(unittest.TestCase):
@patch('call_json_api')
def test_find_definition(self, mock_call_json_api):
mock_response = {u'Definition': 'Visit habr.com'}
mock_call_json_api.return_value = mock_response
expected_definition = 'Visit habr.com'
definition = find_definition('habr')
self.assertEqual(definition, expected_definition)
mock_call_json_api.assert_called_with('http://api.duckduckgo.com/?q=define+habr&format=json')
Мы скрыли операции ввода-вывода, но достаточно ли этого?
Однако важно отметить, что, хотя мы и спрятали операции ввода-вывода за функцией call_json_api
, мы не полностью их разделили. Функция find_definition
по-прежнему зависит от деталей реализации call_json_api
и предполагает, что она будет корректно обрабатывать операции ввода-вывода.
Внедрение зависимостей: Разделение
Для достижения цели разделения этих функции ввода-вывода мы можем использовать внедрение зависимостей.
Обновленная версия функции find_definition:
import requests
def find_definition(word, api_client=requests): # Внедрение заивисимости
q = 'define ' + word
url = 'http://api.duckduckgo.com/?'
url += urlencode({'q': q, 'format': 'json'})
response = api_client.get(url) # I/O
data = response.json() # I/O
definition = data[u'Definition']
if definition == u'':
raise ValueError('that is not a word')
return definition
Добавлен параметр api_client
, который представляет зависимость, отвечающую за выполнение вызовов API. По умолчанию он имеет значение requests, что позволяет нам использовать библиотеку requests для операций ввода-вывода.
Модульное тестирование с внедрением зависимостей
Использование внедрения зависимостей позволяет улучшить контроль и предсказуемость при модульном тестировании. Вот пример того, как можно написать модульные тесты для функции find_definition
с использованием инъекции зависимостей:
import unittest
from unittest.mock import MagicMock
class TestFindDefinition(unittest.TestCase):
def test_find_definition(self):
mock_response = {u'Definition': u'How to switch to Python backend developer!?'}
mock_api_client = MagicMock()
mock_api_client.get.return_value.json.return_value = mock_response
word = 'example'
expected_definition = 'How to add Esports schedules to Google Calendar?'
definition = find_definition(word, api_client=mock_api_client) #функция find_definition с внедрением зависимости
self.assertEqual(definition, expected_definition)
mock_api_client.get.assert_called_once_with('http://api.duckduckgo.com/?q=define+example&format=json')
В обновленном примере модульного теста мы создаем имитатор API-клиента, используя класс MagicMock из модуля unittest.mock. Mock API-клиент настроен на возврат предопределенного ответа, т.е. mock_response
, при вызове метода get
. Ура! Теперь, в случае, если мы захотим использовать другую библиотеку HTTP, мы окажемся в гораздо более выгодном положении.
Проблемы с инъекцией зависимостей
Хотя внедрение зависимостей дает ряд преимуществ, оно также может создавать некоторые проблемы. Как отметил Брэндон, существует несколько потенциальных проблем, о которых следует знать:
Макет против реальной библиотеки: Макетные объекты, используемые для тестирования, могут не полностью повторять поведение реальных зависимостей. Это может привести к расхождениям между результатами тестирования и реальным поведением в процессе работы.
Сложные зависимости: Функции или компоненты с множеством зависимостей, например, комбинация базы данных, файловой системы и внешних сервисов, могут потребовать значительной настройки и управления инъекциями, что усложняет кодовую базу.
Это подводит нас к следующему вопросу.
Отделение операций ввода-вывода от основной логики
Стремясь к созданию гибкого и тестируемого кода, мы можем использовать другой подход, не прибегая к явному внедрению зависимостей. Мы можем добиться четкого разделения проблем, поместив операции ввода-вывода на внешний слой кода. Приведем пример, демонстрирующий эту концепцию:
def find_definition(word): # Listing 3
url = build_url(word)
data = requests.get(url).json() # I/O
return pluck_definition(data)
Здесь функция find_definition
сосредоточена исключительно на основной логике извлечения определения из полученных данных. Операции ввода-вывода, такие, как выполнение HTTP-запроса и получение JSON-ответа, выполняются на внешнем уровне. Кроме того, функция find_definition
также опирается на две отдельные функции:
функция
build_url
строит URL для API-запросаФункция
pluck_definition
извлекает определение из ответа API.
Добавим соответствующие фрагменты кода:
from urllib.parse import urlencode
import requests
def build_url(word):
q = 'define ' + word
url = 'http://api.duckduckgo.com/?'
url += urlencode({'q': q, 'format': 'json'})
return url
def pluck_definition(data):
definition = data[u'Definition']
if definition == u'':
raise ValueError('that is not a word')
return definition
def find_definition(word): # Listing 3
url = build_url(word)
data = requests.get(url).json() # I/O
return pluck_definition(data)
Благодаря тому, что ввод-вывод вынесен на внешний слой, код становится более гибким. Мы успешно создали функции, которые можно тестировать по отдельности и заменять по мере необходимости. Например, можно легко переключиться на другую конечную точку API, модифицировав функцию build_url, или обработать альтернативные сценарии ошибок в функции pluck_definition.
Обновление модульных тестов (еще раз)
Для демонстрации гибкости и контроля, обеспечиваемых модульной конструкцией, давайте обновим наши модульные тесты для функции find_definition
.
import unittest
from unittest.mock import patch
class TestFindDefinition(unittest.TestCase):
@patch('requests.get')
def test_find_definition(self, mock_get):
mock_response = {'Definition': 'Visit habr.com'}
mock_get.return_value.json.return_value = mock_response
word = 'example'
expected_definition = 'Visit habr.com'
definition = find_definition(word)
self.assertEqual(definition, expected_definition)
mock_get.assert_called_once_with(build_url(word))
def test_build_url(self):
word = 'example'
expected_url = 'http://api.duckduckgo.com/?q=define+example&format=json'
url = build_url(word)
self.assertEqual(url, expected_url)
def test_pluck_definition(self):
mock_response = {'Definition': 'What does habr.com do?'}
expected_definition = 'What does habr.com do?'
definition = pluck_definition(mock_response)
self.assertEqual(definition, expected_definition)
if __name__ == '__main__':
unittest.main()
В обновленных модульных тестах теперь есть отдельные методы тестирования для каждого из модульных компонентов:
test_find_definition
остался практически без изменений по сравнению с предыдущим примером до внедрения инъекции зависимостей, проверяя правильность поведения функции find_definition. Однако теперь она утверждает, что функция requests.get вызывается с URL, сгенерированным функцией build_url, демонстрируя обновленное взаимодействие между модульными компонентами.test_build_url
проверяет, что функция build_url правильно строит URL на основе заданного слова.test_pluck_definition
проверяет, что функция pluck_definition правильно извлекает определение из предоставленных данных.
Обновив наши модульные тесты, мы теперь можем тестировать каждый компонент независимо, обеспечивая их корректную работу в отдельности.
Выводы
Вкратце, мы рассмотрели различные подходы к рефакторингу для решения проблемы тесной связи и достижения свободной связи между компонентами. Кроме того, мы увидели, как можно улучшить модульное тестирование за счет имитации операций ввода-вывода и управления поведением внешних зависимостей.