Как улучшить тестируемость кода на примере Dependency Injection в Python

от автора

Несколько недель назад я наткнулся на эту замечательную лекцию Брэндона Родса. Один из ключевых выводов, который я сделал — это важность отделения операций ввода-вывода (т.е. сетевых запросов, обращений к базе данных и т.д.) от основной логики нашего кода. Это позволяет сделать наш код более модульным и тестируемым.

В этой статье я не буду углубляться в тонкости «чистой архитектуры» и перейти сразу к практике!

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 изменится, например в случаях:

  1. использование другой библиотеки HTTP

  2. изменение структуры ответа API

  3. изменение конечной точки 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, мы окажемся в гораздо более выгодном положении.

Проблемы с инъекцией зависимостей

Хотя внедрение зависимостей дает ряд преимуществ, оно также может создавать некоторые проблемы. Как отметил Брэндон, существует несколько потенциальных проблем, о которых следует знать:

  1. Макет против реальной библиотеки: Макетные объекты, используемые для тестирования, могут не полностью повторять поведение реальных зависимостей. Это может привести к расхождениям между результатами тестирования и реальным поведением в процессе работы.

  2. Сложные зависимости: Функции или компоненты с множеством зависимостей, например, комбинация базы данных, файловой системы и внешних сервисов, могут потребовать значительной настройки и управления инъекциями, что усложняет кодовую базу.

Это подводит нас к следующему вопросу.

Отделение операций ввода-вывода от основной логики

Стремясь к созданию гибкого и тестируемого кода, мы можем использовать другой подход, не прибегая к явному внедрению зависимостей. Мы можем добиться четкого разделения проблем, поместив операции ввода-вывода на внешний слой кода. Приведем пример, демонстрирующий эту концепцию:

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 также опирается на две отдельные функции:

  1. функция build_url строит URL для API-запроса

  2. Функция 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() 

В обновленных модульных тестах теперь есть отдельные методы тестирования для каждого из модульных компонентов:

  1. test_find_definition остался практически без изменений по сравнению с предыдущим примером до внедрения инъекции зависимостей, проверяя правильность поведения функции find_definition. Однако теперь она утверждает, что функция requests.get вызывается с URL, сгенерированным функцией build_url, демонстрируя обновленное взаимодействие между модульными компонентами.

  2. test_build_url проверяет, что функция build_url правильно строит URL на основе заданного слова.

  3. test_pluck_definition проверяет, что функция pluck_definition правильно извлекает определение из предоставленных данных.

Обновив наши модульные тесты, мы теперь можем тестировать каждый компонент независимо, обеспечивая их корректную работу в отдельности.

Выводы

Мы кратко рассмотрели различные подходы к рефакторингу для решения проблемы тесной связи и достижения свободной связи между компонентами. Кроме того, мы увидели, как можно улучшить модульное тестирование за счет имитации операций ввода-вывода и управления поведением внешних зависимостей.

Помогайте другим там, где вы это можете делать. (с) Хабраэтикет


ссылка на оригинал статьи https://habr.com/ru/articles/772162/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *