Практика написания хорошо тестируемого и гибкого кода Python

Итак, несколько недель назад я наткнулся на это потрясающее выступление Брэндона Роудса. Меня это так зацепило, что я не смог устоять перед искушением сразу же погрузиться в пример, которым он поделился, и записать свои выводы.

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

Я не буду вдаваться в тонкости Чистой архитектуры или Чистого кода в этом посте. Хотя эти концепции имеют свою собственную ценность, я хочу сосредоточиться на том, что вы можете использовать немедленно.

Давайте погрузимся в пример, который послужит нашей отправной точкой. Этот пример взят непосредственно из слайдов Брэндона Роудса, которые вы можете найти здесь.

Изучение функции find_definition

Представьте, что у нас есть функция Python с именем 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, мы можем использовать встроенный модуль Python 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 tournacat.com'}
        mock_get.return_value.json.return_value = mock_response
        
        expected_definition = 'Visit tournacat.com'
        definition = find_definition('tournacat')
        
        self.assertEqual(definition, expected_definition)
        mock_get.assert_called_with('http://api.duckduckgo.com/?q=define+tournacat&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 теперь делегирует ответственность за создание HTTP-запроса и анализ ответа JSON на функцию call_json_api.

Обновление теста

Опять же, мы используем декоратор 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 tournacat.com'}
        mock_call_json_api.return_value = mock_response
        
        expected_definition = 'Visit tournacat.com'
        definition = find_definition('tournacat')
        
        self.assertEqual(definition, expected_definition)
        mock_call_json_api.assert_called_with('http://api.duckduckgo.com/?q=define+tournacat&format=json')

«У нас есть скрытый ввод-вывод, но действительно ли мы отделили его?»

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

Функция find_definition по-прежнему зависит от деталей реализации call_json_api и предполагает, что она будет корректно обрабатывать операции ввода-вывода.

Внедрение зависимостей: развязка

Мы могли бы дополнительно разделить проблемы ввода-вывода, используя внедрение зависимостей для достижения более развязанного дизайна.

Вот обновленная версия find_definition:

import requests

def find_definition(word, api_client=requests):  # Dependency injection
    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 add Esports schedules to Google Calendar?'}
        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)

        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. Клиент фиктивного 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.

Вот соответствующие фрагменты кода:

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 definitiondef pluck_definition(data):
    definition = data[u'Definition']
    if definition == u'':
        raise ValueError('that is not a word')
    return definition

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

Например, вы можете легко переключиться на другую конечную точку API, изменив функцию build_url, или обработать альтернативные сценарии ошибок в функции pluck_definition.

Такое разделение задач позволяет модифицировать уровень ввода-вывода, не затрагивая основные функции find_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 tournacat.com'}
        mock_get.return_value.json.return_value = mock_response
        word = 'example'
        expected_definition = 'Visit tournacat.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 tournacat.com do?'}
        expected_definition = 'What does tournacat.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 правильно извлечет определение из предоставленных данных.

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

Краткое содержание

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

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

Want to Connect?

This article was originally published at jerrynsh.com.