Несколько недель назад я наткнулся на эту замечательную лекцию Брэндона Родса. Один из ключевых выводов, который я сделал — это важность отделения операций ввода-вывода (т.е. сетевых запросов, обращений к базе данных и т.д.) от основной логики нашего кода. Это позволяет сделать наш код более модульным и тестируемым.
В этой статье я не буду углубляться в тонкости «чистой архитектуры» и перейти сразу к практике!
readme!
Разрешение на перевод статьи от автора получено. Стиль изложения — от первого лица, т.е.от имени автора, но перевод мой и по просьбе Jerry обращаю внимание, что он ответственен только за оригинальную статью на его сайте, но не за перевод. Я постарался перевести как можно точнее с учётом особенностей языка, возможны некоторые правки для улучшения восприятия текста.
Если вы нашли ошибку — используйте, пожалуйста, 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 правильно извлекает определение из предоставленных данных.
Обновив наши модульные тесты, мы теперь можем тестировать каждый компонент независимо, обеспечивая их корректную работу в отдельности.
Выводы
Мы кратко рассмотрели различные подходы к рефакторингу для решения проблемы тесной связи и достижения свободной связи между компонентами. Кроме того, мы увидели, как можно улучшить модульное тестирование за счет имитации операций ввода-вывода и управления поведением внешних зависимостей.
Помогайте другим там, где вы это можете делать. (с) Хабраэтикет
ссылка на оригинал статьи https://habr.com/ru/articles/772162/
Добавить комментарий