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

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

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

  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 правильно извлекает определение из предоставленных данных.

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

Выводы

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

Комментарии (0)