Содержание
- Вступление
- Реальный пример синхронизации с внешним API и типичные тесты
- Альтернативы для мок-объектов в тестировании
- Заключение
Вступление
Добро пожаловать на PyCon 2020, коронавирусная версия. Я — Гарри. Меня можно найти в твиттере, еще есть сайт cosmicpython.com. Уверен, вам интересно, о чем мы там рассказываем. В Древней Греции космос был противоположностью хаоса, и на данном веб-сайте мы говорим о том, как сделать так, чтобы ваш код Python оставался как можно дальше от хаоса.
Именно об этом я и собираюсь поговорить сегодня. Один из возможных способов достичь этого — отказаться от использования мок-объектов. Ладно, должен сказать, что в названии я немного неоднозначно выразился. Не назвал бы это настоящим кликбейтом, но давайте и правда на время откажемся от мок-объектов. Вот что я собираюсь предложить вам сегодня.
Как это все началось? Я известен речами о разработке на основе тестирования (test-driven development), но в последние пару лет я заинтересовался архитектурой программного обеспечения (software architecture). Работаю в компании, в которой мы идем, если можно так выразиться, по пути героя. Мы переживаем этапы стремлений к приключениям, встречи с наставником, начала путешествия и сражений с монстрами.
В данном случае будут не монстры, а структурные решения для кода. Будут свои и чужие, плюсы и минусы, а затем мы вернемся с принцессой. Так закончится путешествие героя. Он вернется в свою деревню, в свое сообщество трубачей с сокровищами, с каким-нибудь эликсиром, с определенными идеями о том, как структурировать код.
Думаю, что у вас получится, если вы перестанете использовать мок-объекты. Возможно, вы узнаете несколько интересных новых способов работы с определенными моментами в своем коде. Об этом я и хочу рассказать сегодня. Поэтому спасибо, что пришли! Если вы смотрите в период изоляции, как и я во время записи, то, независимо от обстановки в вашей стране, надеюсь, вы видите положительные стороны этой странной ситуации, в которой мы все оказались, и сводите к минимуму отрицательные моменты. Насколько это возможно.
Поэтому прошу поднять руку, если вы используете в своем коде мок-объекты. Хорошо, могу предположить, что это будут все. Почему я должен прекратить использовать мок-объекты. Что ж, здесь все дело в изучении новой техники. Не говорю, что мок-объекты — это плохо, и вы никогда не должны их использовать. Как только вы возьметесь за какой-нибудь интересный вид архитектуры и дизайна ПО, вам станет ясно, что все дело в компромиссах. Все зависит от обстоятельств.
Я пытаюсь сказать, что мок-объекты — распространенный инструмент, к которому мы часто обращаемся. Давайте попробуем что-нибудь другое, и вы, возможно, обнаружите, что в одних обстоятельствах более подходящий инструмент у вас будет, а в других — нет. Так что я буду много говорить о плюсах и минусах, здесь нет черного и белого. Так что каждый раз, когда я предлагаю новую идею для паттернов, то буду говорить о ее плюсах и минусах. Отлично. Не будем затягивать, перейдем к теме.
Реальный пример синхронизации с внешним API и типичные тесты
Если вы хоть немного похожи на меня, то учились программированию на практике. Большую часть своих знаний в программировании я получил, работая с разработчиками умнее меня. Большая часть моих знаний об архитектуре программного обеспечения получена от моих коллег, и особенно после встречи, если можно так выразиться, с наставником. Парень по имени Боб. Мы с Бобом в итоге написали об этом книгу под названием Architecture Patterns with Python.
Мы с Бобом работали в определенной сфере бизнеса, и с его помощью я научился некоторым приемам программирования. Поэтому хочу попытаться немного воссоздать все это для вас на примере кода, который мы можем использовать для иллюстрации наших идей.
Пример будет из мира логистики. Представим, что у вас есть грузы, которые вы хотите отслеживать. Набор товаров формируется в отгрузку. В отгрузке будет несколько строк заказа, а строка заказа — это определенное количество некоего товара. Я идентифицирую товар по идентификатору, который называется единицей складского учета (SKU). Возможно, что-то такое вам встречалось. Есть количество, а затем отгрузка из ряда строк в виде списка. Еще у нее есть собственный регистрационный номер, расчетное время прибытия (ETA), код Инкотермс. Вам не нужно разбираться в деталях всего этого. Я пытаюсь показать вам что-нибудь простое.
Мы добавим в пример несколько слов из бизнес-жаргона, чтобы как бы намекнуть на сложность реальной жизни. Знаете, что проблема с презентациями навроде моей в том, что пример должен быть простой, а паттерны и идеи все же наиболее полезны в сложных случаях. Вот чтобы намекнуть на сложность, у меня есть несколько случайных слов из жаргона. Инкотерм (incoterm). Даже не уверен в его значении, это что-то вроде юридического аспекта в связи с государством, в котором груз меняет владельца. Когда он выходит из порта или прибывает в другой порт. Без разницы. Это атрибут отгрузки.
@dataclass
class OrderLine:
sku: str # sku="единица складского учета", по сути, это идентификатор товара
qty: int
@dataclass
class Shipment:
reference: str
lines: List[OrderLine]
eta: Optional[date]
incoterm: str
def save(self):
... # вообразим модель, просто для примера
# она сама знает, как сохраниться в базе данных. Прямо как в Django.
Вот такими будут наши модели, которые мы могли бы использовать для представления данных в своей системе. Возможно, у нас даже есть способ сохранить (save) их в базе данных.
Какое у нас первое бизнес-требование. Когда я создаю отгрузку, мне нужно синхронизировать ее с API грузоперевозчика, то есть с внешним API. Кстати, частично данное выступление возникло из разговора с Брайаном Аахеном (Brian Aachen) из подкаста Test & Code. Он сказал, что авторитетные люди всегда спрашивают, как написать тесты для внешнего API. Вот о чем я собираюсь поговорить сегодня. Я немного рассказал об этом в его подкасте и в блоге на cosmicpython.
Итак, когда нам нужно создать определенный переход для синхронизации с внешним API, как это будет выглядеть? В моем коде есть одна функция, своего рода контроллер или бизнес-сервис. Если вы используете Django или Flask, это может быть что-то вроде view
или какой-то объект, который вы вызываете из view
.
В любом случае, для создания отгрузки возьмем какое-то количество товаров. По сути, я создаю словарь с идентификаторами товаров (sku) и количеством (quantity) каждого из них. И еще Инкотермс. Сгенерируем случайный регистрационный номер с помощью UUID. Создадим объекты-модели, несколько строк заказа и отгрузку. Сохраняемм в базе данных.
def create_shipment(quantities: Dict[str, int], incoterm):
reference = uuid.uuid4().hex[:10]
order_lines = [OrderLine(sku=sku, qty=qty) for sku, qty in quantities.items()]
shipment = Shipment(reference=reference, lines=order_lines, eta=None, incoterm=incoterm)
shipment.save()
sync_to_api(shipment)
Дальше нужно синхронизироваться с API. Я спрятал эту часть в другой функции. Возьмем груз, некоторые атрибуты превратим в простые структуры данных. Я собираюсь отправить их в виде JSON внешнему API. В нашей функции будет requests.post
, немного данных и т.д. Надеюсь, это все знакомо нашим зрителям.
def sync_to_api(shipment):
products = [
{'sku': ol.sku, 'quantity': ol.quantity}
for ol in shipment.lines
]
data = {
'client reference': shipment.reference,
'arrival_date': shipment.eta.isoformat(),
'products': products
}
requests.post(
f'{API_URL}/shipments/',
json=data
)
Мы управляем данными, превращаем их в JSON, чтобы соответствовать требованиям сторонней организации, и запускаем. Пока нормально.
Как это тестировать. Классический вариант — подключить мок-объекты. В частности, модуль unittest.mock
. Есть две конкретные методики: патчи и мок-объекты. Когда я запускаю такой тест, вместо выхода в реальный внешний Интернет мы обращаемся к мок-объекту.
На время работы теста верхний патч мок-объекта не даст requests
выйти в Интернет. Когда мы вызываем свою функцию create_shipment
для создания отгрузки, можно использовать несколько утверждений (assertions) по поводу того, какими должны быть ожидаемые данные. Тогда я могу говорить, что мне нужно вызвать requests.post
с определенными аргументами. Вот так выглядит мой тест, если я использую патч и мок-объект.
def test_create_shipment_does_post_to_external_api():
with mock.patch('controllers.requests') as mock_requests:
shipment = create_shipment({'sku1': 10}, incoterm='EXW')
expected_data = {
'client_reference': shipment.reference,
'arrival_date': None,
'products': [{'sku': 'sku1', 'quantity': 10}],
}
assert mock_requests.post.call_args == mock.call(
API_URL + '/shipments/', json=expected_data
)
Пока нормально. У меня было требование, и мне нужно было написать для него тест. Один тест есть. Хочу сказать, что это не самый красивый и читаемый тест из всех, что я видел, но, конечно, свое задачу выполняет. Если вы пользовались мок-объектами хотя бы несколько раз, то, посмотрев на это все, поймете, что происходит.
Но жизнь никогда не бывает такой простой, да? Допустим, появился второй бизнес, ему нужен requests.post
для новых отгрузок и requests.put
— для существующих. Как вы знаете, обычно API, по какой-то причине, всегда будет в формате RESTful API, и у них может уже иметься отгрузка с данным регистрационным номером. Если направить requests.post
, то это будет ошибка. Нужно направлять requests.put
, потому что модифицируется уже существующая отгрузка.
def sync_to_api(shipment):
external_shipment_id = get_shipment_id(shipment.reference)
if external_shipment_id is None:
requests.post(f'{API_URL}/shipments/', json={
'client_reference': shipment.reference,
'arrival_date': shipment.eta,
'products': [
{'sku': ol.sku, 'quantity': ol.quantity}
for ol in shipment.lines
]
})
else:
requests.put(f'{API_URL}/shipments/{external_shipment_id}', json={
'client_reference': shipment.reference,
'arrival_date': shipment.eta,
'products': [
{'sku': ol.sku, 'quantity': ol.quantity}
for ol in shipment.lines
]
})
def get_shipment_id(our_reference) -> Optional[str]:
their_shipments = requests.get(f"{API_URL}/shipments/").json()['items']
return next(
(s['id'] for s in their_shipments if s['client_reference'] == our_reference),
None
)
Мой код для синхронизации с API внезапно стал намного сложнее. Нужно создавать такой же requests.post
, но при изменении условий нам нужен requests.put
. Самое интересное — проверять, существует что-то или нет. Я могу проверить, внесены ли данные в их базу. То есть, сначала сделать запрос на получение. Возможно, окажется, что они используют немного не такие регистрационные номера, если сравнивать с нашими. Нужно проверять.
Здесь вам тоже не нужно разбираться в специфике кода. Я просто показываю вам такой код, который должен быть знакомым. Более важно то, что нечто, изначально казавшееся простым, стало сложнее. Но именно таким может оказаться прямое требование, с которым вы можете столкнуться в любой день. Еще важнее то, как это влияет на наши тесты.
Давайте подумаем, как сделать тест для requests.put
. Вот таким в итоге у меня получился тест для данного примера.
def test_does_PUT_if_shipment_already_exists():
with mock.patch('controllers.uuid') as mock_uuid, mock.patch('controllers.requests') as mock_requests:
mock_uuid.uuid4.return_value.hex = 'our-id'
mock_requests.get.return_value.json.return_value = {
'items': [{'id': 'their-id', 'client_reference': 'our-id'}]
}
shipment = create_shipment({'sku1': 10}, incoterm='EXW')
assert mock_requests.post.called is False
expected_data = {
'client_reference': 'our-id',
'arrival_date': None,
'products': [{'sku': 'sku1', 'quantity': 10}],
}
assert mock_requests.put.call_args == mock.call(
API_URL + '/shipments/their-id/', json=expected_data
)
Мне нужно сделать патч не только на модуль requests
, но и на модуль UUID
. Нужно убедиться в том, что в requests.get
есть объект, уже имеющий UUID, который генерируется функцией create_shipment
.
Я добавил утверждение (assertion) о том, что requests.post
будет ложным (False) и следует вызвать requests.put
. Тест стал намного больше. Не так радостно смотреть на эти два теста, мы опустились на средний уровень ужаса. У меня два мок-объекта. Построить логическую цепочку при изучении данных тестов немного утомительнее. Думаю, с такой ситуацией все знакомы.
Давайте просто продолжим и перейдем на следующий уровень.
Третье требование. Вы направили отгрузку сторонней организации. Теперь она знает, как доставить ваш груз на корабль. Самое главное, нам нужно знать расчетное время прибытия (ETA) данных товаров, потому что тогда нам нужно знать, когда мы сможем продать их реальным покупателям.
Теперь вы знаете, когда он сюда попадет. Чаще всего мы пользуемся внешним API, чтобы узнать время прибытия. Кода будет намного больше. Вам не обязательно понимать конкретику происходящего. Примерно такой код вы могли бы написать, чтобы добраться до этой воображаемой отгрузки. Этому воображаемому кораблю нужен API. Просто представьте себе то количество тестов, которое может вам понадобиться для такого рода событий.
Я попробовал написать код, который мог бы немного больше походить на типичный бизнес-код, с которым мы работаем каждый день.
# еще один пример контроллера,
# показываем, как бизнес-логика переплетается с вызовами API
def get_updated_eta(shipment):
external_shipment_id = get_shipment_id(shipment.reference)
if external_shipment_id is None:
logging.warning('tried to get updated eta for shipment %s not yet sent to partners', shipment.reference)
return
[journey] = requests.get(f"{API_URL}/shipments/{external_shipment_id}/journeys").json()['items']
latest_eta = journey['eta']
if latest_eta == shipment.eta:
return
logging.info('setting new shipment eta for %s: %s (was %s)', shipment.reference, latest_eta, shipment.eta)
if shipment.eta is not None and latest_eta > shipment.eta:
notify_delay(shipment_ref=shipment.reference, delay=latest_eta - shipment.eta)
if shipment.eta is None and shipment.incoterm == 'FOB' and len(shipment.lines) > 10:
notify_new_large_shipment(shipment_ref=shipment.reference, eta=latest_eta)
shipment.eta = latest_eta
shipment.save()
(if external_shipment_id is None...) У меня было бы что-нибудь для ситуации, когда я пытаюсь найти судно, а его нет, и в этом есть какая-то ошибка. Поэтому мне нужен тест для этой ситуации, где мы бы указали на отсутствие нашей отгрузки вместе с судном. Вот предупреждение (warning). Вот что мы тестируем на самом деле.
([journey] = requests.get...) Здесь мне нужно вытащить новую конечную точку API. Поэтому мне нужен мок-объект на эту новую конечную точку API. На выходе у нас бизнес-кейс, в котором мы как бы говорим: хорошо, изменений нет, и в таком случае будет досрочный возврат. Мне нужен тест для этого.
(if shipment.eta is not None and latest_eta...) Потом обратная сторона медали: предположим, что все не так. Все иначе, и значение ETA больше, чем наше текущее ETA, а это означает небольшую задержку. Я собираюсь представить так, будто бизнес хочет, чтобы мы сделали какое-нибудь уведомление. Например, уведомить, что данная отправка задерживается. Поэтому тест будет именно об этом. Возможно, мы еще добавим тест, когда как бы действительно заметили, что это не так. Получается еще два теста.
(if shipment.eta is None and shipment.incoterm...) После этого я мог бы представить себе еще один бизнес-кейс, в котором наш конкретный инкотерм со строками длиннее определенного значения означает, что отгрузка будет большой. Тогда нам нужно идти через другой процесс. То есть, у нас еще один тест, и мы его сохраняем.
По моим подсчетам получается не меньше 12 тестов. Каждый из этих тестов должен будет имитировать три разных события и совершить четыре вызова, а затем вызвать get
для существующих отгрузок, а также post
и put
.
Придумаем еще одно требование. Говорят, что тесты с мок-объектами хрупкие. Каждый патч говорит о том, что мы собираемся заменить мок-объектом вызов requests.get
в своем модуле. Но если вместо import requests
написать from requests import get
, то тесты перестают работать.
Еще можно использовать requests.Session()
, потому что его нужно передавать, например, для повышения производительности. То есть, вы собираетесь повторно использовать тот же сеанс или использовать файлы cookie или что-то вроде этого.
Очень часто небольшие изменения в коде реализации, которые не имеют реального значения для того, как мы делаем запросы, сломают все мок-объекты. Поэтому, когда мы используем такие мок-объекты и патчи, то наши тесты станут хрупкими.
Итак, давайте резюмируем плюсы и минусы. Буду говорить о них с разных сторон. Мы обращаемся к мок-объектам, потому что разбираемся в них, то есть нам не нужно вносить новые изменения в написанный код.
Я могу просто сделать свою надстройку для API с помощью requests
. Магия обезьяних патчей (monkey patching) подключается к тестам и помогает написать такой тест, который не взаимодействует с настоящим Интернетом. Все это нам хорошо знакомо знакомо и не требует больших усилий.
Конечно же, писать такие тесты довольно сложно. Но они не требуют больших усилий в том смысле, что мне не нужно слишком много думать об изменениях. Я могу просто использовать мок-объекты, как я делаю всегда и везде.
Надеюсь, мне удалось описать недостатки. Я показал, что присутствие мок-объектов означает тесную привязку тестов к деталям реализации. Например, к определенному способу, которым вы импортируете то, что заменяете мок-объектом.
Получается, что ваши тесты проверяют детали реализации, а не поведение.
Второй, немного более тонкий момент может заключаться в том, что нужно помнить, какой патч нужно связывать с каждым тестом.
Если вам такое встречалось, то иногда при запуске тестовый комплекс unittest
внезапно зависает. И тут вы вспоминаете: ах да, один из этих тестов вызывает данный объект, а тот выходит в Интернет, а Интернет работает медленно, и вот теперь тесты зависли. Нужно не забыть поставить моки-патчи на все тесты, которые могут взаимодействовать с внешним API.
Наконец, такой подход слишком упрощает смешивание бизнес-логики и задач ввода-вывода. Все моменты в тестах, связанные с JSON, получением конечной точки и идентификаторов, не имеют ничего общего с бизнес-логикой, которую я пытался изобразить в примере.
Суть бизнес-логики в том, что вам известно о задержке отгрузки, а затем вы уведомляете вот этих людей. Если партия окажется крупной и прибывает как раз вовремя, то нужно сделать вот это.
В своем коде я хотел бы разделить JSON, что будет в словаре (dictionary), и, например, бизнес-правила. Мок-объекты не заставляют меня делать это. Кроме того, я могу написать один из этих мок-объектов, но мне по-прежнему нужны какие-то интеграционные тесты или сквозные тесты, чтобы убедиться в том, что все действительно работает.
Возможно, вы слышали про тестовые мок-объекты в Java. Люди отрывались от реального мира настолько далеко, что нужно было отдельно проверять, что все действительно работает.
Другими словами, вы можете оказаться в одном месте, которое мой друг и технический контролер Эд Юнг называет адом мок-объектов.
Эд, кстати, выступал на прошлогоднем PyCon. Так что, если вы видели его выступление, которое называется "Ловушки мок-объектов и патчей в Python", которому он еще дал альтернативное название "Ад мок-объектов", то мне нравится думать, что мое выступление является своего рода кратким резюме и сиквелом.
Альтернативы для мок-объектов в тестировании
Поговорим об альтернативах. Например, прекратим использовать мок-объекты. Что же нам делать. Вот мои предложения.
Предложение номер один. Построить адаптер. Что я понимаю под адаптером. Давайте создадим оболочку вокруг внешнего API, который мы вызываем. Давайте создадим оболочку вокруг нужного мне ввода-вывода и дадим ему какой-нибудь API. Давайте отделим его от ядра нашего приложения, ядра нашей бизнес-логики.
Итак, тонкая оболочка. Хотя я использую слово «адаптер» вместо того, чтобы просто сказать «оболочка». Это немного напоминает порты и адаптеры, то есть архитектурный паттерн. Мы говорим об этом подробнее в книге, которую можно найти на cosmicpython.
Не уверен, упоминал ли я, что порты и адаптеры — это архитектурный паттерн. Если вы читали об этом, вам, возможно, встречалась гексагональная архитектура (hexagonal architecture), порты и адаптеры (ports and adapters), луковичная архитектура (onion architecture) или чистая архитектура (clean architecture). Все это очень похожие между собой архитектуры.
Среди прочего, я пытаюсь помочь вам отделить проблемы инфраструктуры (нижний уровень) от бизнес-логики (верхний уровень). Именно поэтому я создаю адаптер. Давайте посмотрим на пример, чтобы увидеть, на что это похоже. Здесь я собираюсь использовать класс для представления своего адаптера.
class RealCargoAPI:
API_URL = 'https://example.org'
def sync(self, shipment: Shipment) -> None:
external_shipment_id = self._get_shipment_id(shipment.reference)
if external_shipment_id is None:
requests.post(f'{self.API_URL}/shipments/', json={
...
else:
requests.put(f'{self.API_URL}/shipments/{external_shipment_id}/', json={
...
def _get_shipment_id(self, our_reference) -> Optional[str]:
try:
their_shipments = requests.get(f"{self.API_URL}/shipments/").json()['items']
return next(
...
except requests.exceptions.RequestException:
...
Не обязательно прибегать к классам. Я предпочитаю избегать использования классов везде, где только возможно. Если вы никогда не видели классику PyCon Джека Дидериха (Jack Diederich) под названием "Прекращаем писать классы", очень рекомендую.
Но ради примера будет удобно представить API как что-то осязаемое. Сделаем класс, и у него будет API. Что я имею в виду под API, у которого есть API. Это будет пара публичных методов.
Итак, что я пытаюсь сделать, когда создаю данную оболочку. Я говорю себе: хорошо, что делает моя бизнес-логика, что моему приложению нужно от этой внешней зависимости на моем собственном языке, а не на языке сторонней организации.
Мне нужно синхронизировать свои объекты с ним, и мне нужно получить последнее ETA. Вы можете называть это как-то иначе, например отправкой или инициализацией. Тогда получение последней версии ETA можно было бы назвать получением доставки, обновлением или еще как угодно. Но я собираюсь формулировать на своем языке, что имеет смысл и хорошо смотрится в моем коде.
Затем я собираюсь скрыть язык и сложности низкоуровневых деталей данного API за оболочкой, за моим адаптером. Все эти моменты по поводу получения идентификатора отгрузки и выяснение, следует ли вызывать requests.post
или requests.put
, я могу просто проигнорировать в коде своего приложения.
Даже когда я хотел показать этот пример кода в самом первом примере отгрузке, я автоматически поместил синхронизацию с API в отдельную перекошенную функцию.
Просто хочу сказать, сделайте этот явный шаг в своей структуре, когда нужно синхронизироваться с чем-то внешним в Интернете. Предлагаю делать адаптеры. Создавайте объекты, давайте им имена. Это точно скажется на тестах.
При создании теста вместо патчей для модуля requests
, я сделаю патч своему собственному классу API для реального груза (RealCargoAPI). Затем вызываю функцию create_shipment
, и вот тогда будет вызван API для мок-объекта груза.
def test_create_shipment_syncs_to_api():
with mock.patch('controllers.RealCargoAPI') as mock_RealCargoAPI:
mock_cargo_api = mock_RealCargoAPI.return_value
shipment = create_shipment({'sku1': 10}, incoterm='EXW')
assert mock_cargo_api.sync.call_args == mock.call(shipment)
Вспомним самый первый тест. Мой assert
выглядел как assert.mock.requests.post.json
в двойных кавычках. Как минимум, я немного упростил внешний вид своего теста, потому что вместо добавления мок-объекта для модуля requests
и всех его сложностей я добавляю мок-объект для API, который находится под моим контролем, и это будет немного чище.
При этом не забываем, что:
- мы не избавились от хрупкости mock.patch
, то есть, если мы поменяем структуру импортов, то мок-объекты нужно поменять следом;
- нам нужен тест для самого адаптера API.
def test_sync_does_post_for_new_shipment():
api = RealCargoAPI()
line = OrderLine('sku1', 10)
shipment = Shipment(reference='ref', lines=[line], eta=None, incoterm='foo')
with mock.patch('cargo_api.requests') as mock_requests:
api.sync(shipment)
expected_data = {
'client_reference': shipment.reference,
'arrival_date': None,
'products': [{'sku': 'sku1', 'quantity': 10}],
}
assert mock_requests.post.call_args == mock.call(
API_URL + '/shipments/', json=expected_data
)
Это был первый шаг. Теперь давайте сделаем еще один шаг, чтобы рассмотреть плюсы и минусы. С профессиональной точки зрения, люди, которые увлекаются разработкой на основе тестирования, методами тестирования и методиками использования мок-объектов и патчей, придерживаются такого эмпирического правила: не делайте мок-объекты для того, что вам не принадлежит. Не могу сказать, что понимаю этот момент в полной мере.
Однако то, что мы сейчас обсуждаем, частично имеет отношение к данному правилу, а именно: когда я делаю мок-объекты для библиотеки requests
, она может выполнять сотни операций, выполняя post
и put
, она может создавать сессии, адаптеры, которые, как вы знаете, меняют HTTP-запросы.
Но когда я создаю мок-объект на свой класс API для груза, все намного проще. Мне не нужно беспокоиться, что мой код, в котором всего лишь две функции, синхронизируется и получит последнее ETA. Поэтому мне не нужно беспокоиться насчет post
, патчей, опций или чего-то еще. То есть, мок-объекты легче писать, и у меня больше уверенности в том, что на самом деле делает мой код, потому что мои мок-объекты проще.
Возможно, на более концептуальном уровне я уже отделил код инфраструктуры от бизнес-логики. Итак, я обозначил, чего хочу от API для груза. У меня должна быть возможность синхронизироваться и получать последнее ETA.
Допустим, опаздывающий бизнес говорит, знаете что, забудьте про эту стороннюю организацию, мы собираемся поговорить с другой организацией и хотим направить свои отгрузки именно ей. А те используют совершенно другую систему интеграции, например XML вместо JSON, потому что они поклонники 90-х.
Я могу пойти еще дальше и внести всевозможные изменения в способ интеграции и создать новую версию оболочки и адаптера. Но, скорее всего, у них будет все тот же метод синхронизации и тот же метод получения последнего ETA. Зато не нужно менять все тесты для моей базовой бизнес-логики. Вот так я обеспечил себе небольшую уверенность на будущее, позволил инфраструктурным моментам и бизнес-логике меняться независимо друг от друга. Мне удалось немного ослабить связанность элементов системы (decoupling).
Вот такие плюсы и минусы. Ничего не бывает бесплатно, кода стало больше. Мне пришлось создать данный класс, который в другой ситуации был бы не нужен. Мне пришлось добавить слой, а это всегда дополнительное усложнение.
На это будут смотреть и говорить, да неужели, самый настоящий API для груза, который нужен для синхронизации. Раньше можно было просто посмотреть на requests.post
. Все знают про requests
, и все точно знают, что делает requests.post
. Это был первый шаг.
Но мы не собираемся останавливаться на достигнутом. Какой у нас заголовок выступления: "Прекратите пользоваться мок-объектами"! Так что давайте перейдем к отличному предложению, то есть создадим файк для адаптера. Итак, прекращаем использовать мок-объекты часть 1: вместо мок-объектов я рекомендую вам создать фейк.
Фейк вместо мок-объекта
Что такое фейк, в чем разница между фейком и мок-объектом. Я не хочу слишком подробно говорить об этом. По сути, мок-объект притворяется, будто он что-то делает, а потом я задаю вопросы: эй, мок-объект, кто тебя вызывал и какие атрибуты при этом задействовались.
Фейк — другой вид двойников для тестов, другой вид поддельного объекта, который используется для выгрузки. Самая настоящая зависимость от фейковой зависимости. Вы не задаете вопросы, а он создает упрощенную, но функциональную версию того реального объекта, который вам нужно заменить. Посмотрим на реальном примере. Думаю, в этом будет больше смысла.
Знаете, если вам кажется, что можно создать абстрактный базовый класс (abstract base class) или вам действительно нравятся подсказки о типах (type hints) и PEP 544, вы можете сделать протокол, который говорит, что API для груза нужны эти два метода: получить последнее ETA (get_latest_eta) и синхронизироваться (sync). То есть, настоящий объект, который взаимодействует с настоящим Интернетом.
Он получит последнее ETA при синхронизации, а затем я создам фейковую версию того же объекта с этими двумя публичными методами: получить последнее ETA и синхронизироваться. Он будет ездить как настоящая машина, но на самом деле является подделкой. Она просто будет хранить все в памяти.
Очень часто вы в конечном итоге будете делать именно так.
class FakeCargoAPI:
def __init__(self):
self._shipments = {}
def get_latest_eta(self, reference) -> date:
return self._shipments[reference].eta
def sync(self, shipment: Shipment):
self._shipments[shipment.reference] = shipment
def __contains__(self, shipment):
return shipment in self._shipments.values()
Это просто небольшой объект в памяти. Если я что-то синхронизирую с ним, он принимает объект отгрузки, помещает его в небольшой словарь с ключом в виде регистрационного номера. Если я попрошу последнее ETA, он пойдет и посмотрит в том словаре, получит регистрационный номер и вернет вам ETA для данной отгрузки.
Затем я добавлю небольшой метод с двойным подчеркиванием (dunder) contains
. В нем будет оболочка вокруг определенного контейнера. Данный метод будет прекрасным образчиком синтаксического сахара, которым я воспользуюсь в следующем разделе.
Посмотрим, как это повлияет на тесты. Я все еще использую патч, но собираюсь создать экземпляр своего фейкового объекта API. Использование мок-объекта для API не возвращает никакое значение. В данных тестах мы будем использовать этот фейк вместо мок-объекта.
@mock.patch('controllers.RealCargoAPI')
def test_create_shipment_syncs_to_api(mock_RealCargoAPI):
fake_api = FakeCargoAPI()
mock_RealCargoAPI.return_value = fake_api
shipment = create_shipment({'sku1': 10}, incoterm='EXW')
assert shipment in fake_api
Когда я вызываю функцию create_shipment
, она будет использовать этот фейковый API, и тогда мое утверждение (assertion) будет просто поиском отгрузки в фейковом API. Когда я создаю отгрузку, она должна в итоге оказаться в фейковом API.
Надеюсь, вы согласитесь с тем, что в итоге получился гораздо более читаемый тест, чем то, что было у нас раньше. Если вернуться к мок-объекту для requests.post
, можно увидеть, что нам удалось сделать тесты более удобочитаемыми с помощью фейкового API. Так что да, получить более хорошо читаемые тесты реально.
Что касается минусов. В моих тестах еще больше кода. Я создал адаптер, у меня больше кода в приложении в связи с созданием фейка. У меня больше кода в тестах, ведь мне пришлось создать целый класс для фейкового API. Я возложил себя бремя обслуживания при каждом изменении реального API, ведь мне нужно вернуться и руками поменять свой фейковый API таким образом, чтобы мне не нужно было иметь никаких дел с мок-объектом.
Так что я придумал себе дополнительную работу. Но за счет этого я кое-что получил: более читаемые тесты. Возможно, будет еще какая-нибудь польза.
Но, вообще, данная идея создает диктует структуру кода. Это интересный момент. Суть в том, что когда я пытаюсь создать свой адаптер, адаптер для API реального груза, я пытаюсь сделать его изящным. Именно это диктует структуру кода.
Допустим, чтобы использовать его в коде, я пишу метод create_shipment
. После этого я перехожу к API. Изящество в том, что я пользуюсь им еще в одном месте. И вот как раз заставляя себя повторно реализовать данный API в виде фейка, я получу гораздо больше сигналов о том, удалось ли мне сделать изящную, простую и пригодную для использования оболочку.
Чем сложнее вам создать фейк для своего объекта, тем больше вы будете спрашивать себя, не слишком ли он сложный. Если создание фейка станет по-настоящему сложным, вы, возможно, подумаете: подождите, почему так сложно.
Именно это я понимаю под давлением структуры кода. Тяжелая работа, которую вы сами на себя взваливаете с этим фейком, может дать больше сигналов, помогающих сохранить код чистым и управляемым. Вот такая теория. Составить мнение по данному моменту вы сможете только на практике. Именно это лежит в основе всего этого выступления: дать вам идеи. Пробуйте.
Инъекция зависимости
Итак, без лишних слов перейдем к следующему моменту, который я хочу предложить вам на пробу. Если вы подумали, что пример с API вынуждает делать больше избыточной работы с сомнительными преимуществами, то следующий пример — практически ругательство или табу в мире Python, а именно: нужно использовать инъекцию зависимости (dependency injection). Это будет вторая часть моего выступления, в котором я призываю отказываться от мок-объектов.
Я рассказывал о том, что вместо мок-объектов следует использовать фейки. Вторая часть будет про то, что вместо обезьяньего патча (monkey-patching) вы получаете один независимый мок-объект. Но если вариант с инъекцией зависимости не подходит или мешает, то не стоит пытаться его использовать.
Если подумать, это тоже похоже на java. Я не пытаюсь изменить всю вашу жизнь, я просто пытаюсь заставить попробовать что-нибудь. Вместо того, чтобы пробовать код в реальной жизни, почему бы вам просто не посмотреть, как выглядит код в моем примере.
Итак, инъекция зависимости подразумевает просто добавление дополнительного аргумента к функции create_shipment
.
def create_shipment(
quantities: Dict[str, int],
incoterm: str,
cargo_api: CargoAPI
) -> Shipment:
...
# остальной код контроллера тот же.
По сути, это означает, что для создания отгрузки мне нужно знать вид продуктов и их количество. Мне нужно знать инкотерм, потому что это нужно бизнесу. Еще мне нужен API для груза, потому что отгрузка создается через синхронизацию с данным API.
Я собираюсь предусмотреть это прямым образом в функции, которая создает отгрузки, а остальной код не изменится. Вместо того, чтобы создавать экземпляр груза через импорт API для груза и создавать его экземпляр его в данном модуле, я собираюсь передать его в виде аргумента. Это и есть инъекция.
Несмотря на передачу зависимости в виде аргумента, нужно проверить, как это повлияет на тесты. Поэтому я создаю экземпляр своего фейкового API, и вы заметите, что в верхней части теста больше нет мок-объектов и патчей, когда я вызываю функцию create_shipment
. Я просто передаю этот фейковый API в виде аргумента.
def test_create_shipment_syncs_to_api():
fake_api = FakeCargoAPI()
shipment = create_shipment(
{'sku1': 10}, incoterm='EXW',
cargo_api=fake_api
)
assert shipment in fake_api
Думаю, что это самый простой и понятный тест для данной части кода. Опять же, если вы сравните его со старой версией с его мок-объектами и патчами, то увидите, что новая гораздо проще.
Есил вернуться назад к варианту с вызовом requests.post
и обезьяним патчем для модуля UUID, то станет очевидно, что новый подход даст, как минимум, вот такие действительно изящные и читаемые тесты.
Это и есть один из плюсов: более изящные тесты. Но второй возможный плюс — более тонкий момент. Вспомните, когда нам приходилось не забывать исправлять каждый тест, который мог бы взаимодействовать с внешним API.
В новой версии нужные объекты стали обязательными аргументами. Поэтому я не смогу написать тест для create_shipment
, не передав какой-нибудь API для груза. Мне не нужно помнить. Когда я его вызову, он сам попросит меня об этом.
Есть еще одна неоднозначная польза. Зависимость теперь более явная. Мне так и говорили, когда я впервые заинтересовался идеей об использовании инъекции зависимости. Наверное, это спорно, но, знаете, когда я смотрю на функцию, то сразу вижу, что ей нужно API для груза.
Когда я пытаюсь выяснить, какие части моего кода зависят от Интернета или от внешних зависимостей (возможно, синхронизируется какое-то хранилище файлов или отправляются твиты), это можно увидеть прямо в вызываемой функции. Вам не нужно искать реализацию или следить, импортирует ли она requests
.
Ваши зависимости будут явными. Лучший способ узнать, хорошо это или нет, — попробовать. Разумеется, за все надо платить цену. Мой производственный код увеличился за счет этого, возможно, ненужного аргумента. Затем внедрение зависимостей, например, передача данных для зависимостей, становится более обременительным. Чем больше слоев, тем труднее работать.
Например, на данный момент у меня есть только функция create_shipment
, которая использует API. Но представим, что create_shipment
вызывает какой-нибудь объект, допустим, переоценку отгрузки. Тот вызывает что-нибудь еще, говоря, что отгрузка отменяется. В результате происходит синхронизация точек API. В такой ситуации мне пришлось бы передавать данный аргумент по цепочке функций.
Есть разные способы разобраться с этим. Можно использовать библиотеки инъекции зависимостей. Они полезные. Но сейчас я не хочу спорить по поводу них. Они не всегда мне нравятся. Еще можно использовать архитектурные шаблоны. Нужно постараться минимизировать сложность данного подхода. Немного подробнее мы говорим об этом на cosmicpython и в книге, которую я написал. Совершенно бесплатно. Вы можете абсолютно бесплатно прочитать все, что я написал.
Но да, чем больше слоев, тем выше может быть риск. Так что надо пробовать и смотреть, что перевешивает: плюсы или минусы.
Кстати, хотелось бы сказать насчет ненужного аргумента. Это случилось со мной в реальной жизни с настоящим аргументом. Я застрял, просто не мог ничего придумать. Мне говорили, что мы не будем пытаться внедрить зависимость, не купимся на это все. Не будем добавлять этот ненужный аргумент в функцию. Мы просто импортируем, это самый чистый способ, ведь мы импортируем наверху, а затем уже код. А ты просишь добавить к данной функции дополнительный странный и ненужный аргумент. Мы отказываемся уродовать код приложения только ради того, чтобы тесты стали лучше.
Может, это Стокгольмский синдром? Но я справился. Думаю, вам стоит хотя бы попробовать и увидеть, на что это похоже. На самом деле, а что еще можно сделать такого, что оправдывало бы изменение кода приложения для повышения его читаемости, удобства в обслуживании, улучшения организованности? Все это веские причины для изменения кода приложения.
Так вот, я застрял и говорил о том, что упрощение написания теста — неприемлемая причина. Но, в конце концов, тесты — часть вашего приложения. Знаете, если можно сделать тесты вдвое проще и удобнее в обслуживании за счет усложнения читаемости и обслуживания приложения на 10%, возможно, это хороший компромисс. Хотя бы подумайте об этом, ладно.
Как тестировать адаптер
Итак, в заключение, у меня есть еще пара моментов, о которых нужно поговорить. Допустим, вы мне говорите, что перестали использовать мок-объекты, но ведь наша изначальная проблема с мок-объектами никуда не делась. Нам все равно понадобится какой-то настоящий тест, чтобы проверить, действительно ли это все работает.
Вы можете написать тест для проверки работоспособности фейка. Но вам все равно нужно будет проверить, работает ли реальный объект. И как проверить реальный адаптер? Я попробовал проверить, работает ли мой адаптер, и, возможно, использую мок-объект для проверки. Предположу, что задача вашего адаптера заключается только в интеграции со сторонней организацией.
Хочу предложить самые лучшие тесты для этого, интеграционные тесты. Итак, давайте посмотрим, что будет, если бы я все-таки использовал мок-объект и патч для тестирования API. Создаем экземпляр API, клонируем реальный API груза. Вызываем API.sync
. В конце будет вызов mock.patch
. Вот так у меня снова получилось такое же уродство, что и в самом начале.
def test_sync_does_post_for_new_shipment():
api = RealCargoAPI()
line = OrderLine('sku1', 10)
shipment = Shipment(reference='ref', lines=[line], eta=None, incoterm='foo')
with mock.patch('cargo_api.requests') as mock_requests:
api.sync(shipment)
expected_data = {
'client_reference': shipment.reference,
'arrival_date': None,
'products': [{'sku': 'sku1', 'quantity': 10}],
}
assert mock_requests.post.call_args == mock.call(
API_URL + '/shipments/', json=expected_data
)
По возможности, старайтесь избегать таких вариантов, используйте интеграционный тест. Загружаем реальный API груза и отправляем его в какую-нибудь песочницу (sandbox). Если повезет, у сторонней организации, с которой вы взаимодействуете, есть песочница или тестовая учетная запись, которой можно воспользоваться, а затем фактически синхронизировать с ней реальную отгрузку или по-настоящему настроить объект.
def test_can_create_new_shipment():
api = RealCargoAPI('https://sandbox.example.com/')
line = OrderLine('sku1', 10)
ref = random_reference()
shipment = Shipment(reference=ref, lines=[line], eta=None, incoterm='foo')
api.sync(shipment)
shipments = requests.get(api.api_url + '/shipments/').json()['items']
new_shipment = next(s for s in shipments if s['client_reference'] == ref)
assert new_shipment['arrival_date'] is None
assert new_shipment['products'] == [{'sku': 'sku1', 'quantity': 10}]
Затем, когда захочется проверить, сработало или нет, я вызову данное приложение с помощью requests
, и увижу, успешно ли доставлена отгрузка. Думаю, что это лучше всего покажет вам, что все действительно работает. Обратите внимание, что это тест для requests.post
, который раньше был основным способом.
Теперь перейдем ко второму тесту, который мы использовали в примере с requests.put
, где отгрузка уже существовала. Мы подробно рассмотрим наш обезьяний патч для модуля UUID и убедимся, что она уже существует.
Этот тест может стать намного понятнее. Суть примера в том, что если мы можем обновить отгрузку (update_shipment), то вторым запросом должен быть requests.put
. Я просто сделаю два запроса requests
. Синхронизируемся с внешним объектом, поменяем его, потом снова синхронизируемся и создадим утверждение о том, что все получилось. На самом деле, под капотом используется порт, а не requests.post
, но это неважно.
def test_can_update_a_shipment():
api = RealCargoAPI('https://sandbox.example.com/')
line = OrderLine('sku1', 10)
ref = random_reference()
shipment = Shipment(reference=ref, lines=[line], eta=None, incoterm='foo')
api.sync(shipment)
shipment.lines[0].qty = 20
api.sync(shipment)
shipments = requests.get(api.api_url + '/shipments/').json()['items']
new_shipment = next(s for s in shipments if s['client_reference'] == ref)
assert new_shipment['products'] == [{'sku': 'sku1', 'quantity': 20}]
Вы понимаете идею о тестировании поведения, а не деталей реализации? Обратите внимание на то, что отгрузка уже существует, и она уже один раз синхронизирована мной или кем-то еще. При второй синхронизации все продолжает работать.
Важно то, что все по-прежнему работает точно так же, а не то, что вы успешно направили requests.post
. Так что данный тест будет изящнее и удобнее для чтения, а еще — ближе к тому, чего вы хотите. Интеграционный тест может подойти лучше всего.
Можно ли протестировать ваш адаптер? Тогда точно нужна тестовая песочница. Ее может и не быть. Вопрос — хороший. Но даже если у вас есть одна тестовая песочница, сторонняя организация может не вкладывать столько денег в свои тесты и записи, потому что они, например, в процессе создания реальной производственной системы. Все может работать медленно и нестабильно, в результате чего тесты будут такими же. Мы все знаем, как ужасна такая ситуация.
Не забывайте подчищать. Допустим, у вас три или четыре разных разработчика, которые работают над этим весь день. Нужно убедиться, что вы не наступаете друг другу на пятки. Вам нужно всегда генерировать уникальные идентификаторы. Представьте себе, что три или четыре разработчика запускают тест пять-десять раз в час. А ведь еще есть непрерывный цикл интеграции, запускающий ваш тест каждый раз, когда кто-то выполняет git push
.
Точно помню, как одна сторонняя организация связалась с нами через две недели после запуска процесса интеграции и сказала, что в учетной записи песочницы сотни тысяч новых объектов. Чем вы там занимаетесь? Тогда мы так и не придумали, как их удалить, и песочница становилась все медленнее и медленнее. Так что это все точно вам пригодится.
Фейковый API
Самое время перейти к моему третьему или последнему предложению. Нет ничего плохого в создании фейкового API для интеграционных тестов. Допустим, у вас есть реальные проблемы при интеграции с реальным объектом. Так почему бы не создать фейк.
Конечно, здесь определенно есть небольшая опасность. Но давайте просто посмотрим, насколько легко будет создать фейковую версию сторонней организации, с которой вы интегрируетесь, вместо того, чтобы зависеть от реальной сторонней организации.
Может, просто развернуть маленький контейнер docker рядом с остальными контейнерами, и он просто будет притворяться этим внешним API. Он будет вести себя точно так же, и это на самом деле удивительно просто. Я написал несколько таких в свое время. Они обычно умещаются на одной странице кода, потому что большинство объектов, которые вам нужно интегрировать, просто создают оболочку для чтения, обновления и удаления данных вокруг какой-нибудь базы данных.
from flask import Flask, request
app = Flask('fake-cargo-api')
SHIPMENTS = {} # type: Dict[str, Dict]
@app.route('/shipments/', methods=["GET"])
def list_shipments():
print('returning', SHIPMENTS)
return {'items': list(SHIPMENTS.values())}
@app.route('/shipments/', methods=["POST"])
def create_shipment():
new_id = uuid.uuid4().hex
refs = {s['client_reference'] for s in SHIPMENTS.values()}
if request.json['client_reference'] in refs:
return 'already exists', 400
SHIPMENTS[new_id] = {'id': new_id, **request.json}
print('saved', SHIPMENTS)
return 'ok', 201
@app.route('/shipments/<shipment_id>/', methods=["PUT"])
def update_shipment(shipment_id):
existing = SHIPMENTS[shipment_id]
SHIPMENTS[shipment_id] = {**existing, **request.json}
print('updated', SHIPMENTS)
return 'ok', 200
Обычно REST API говорит, что если вы мне что-нибудь отправляете, а затем направляете get
, то обратно получите тоже самое в более или менее том же виде. Я могу сделать фейк для такой ситуации, просто сказав, что если вы создаете post
, я сохраняю все в памяти. Я выловлю именно тот JSON, который вы мне отправили, и отправлю его вам.
Создать фейковую версию внешней организации удивительно просто. Благодаря этому у вас будут более надежные тесты. Потому что взаимодействие происходит по настоящему Интернету, вы общаетесь с фейком, который вы контролируете.
Как известно, бывает так, что в реальной жизни ничего не выходит из строя, но тесты никогда не проходят, потому что внешняя песочница нестабильна или происходит что-то еще. Это не так важно для вашего приложения. Вам интересно, почему вы дважды в неделю переделываете свою сборку, при этом в реальной жизни все нормально.
Почитать про контрактные тесты и VCR.py
Последняя идея. Есть и другие темы, которыми можно заняться, когда у вас появится рабочий фейк. Допустим, у вас есть серия тестов, которые проходят против реальной и фейковой зависимости. Это иногда называют подтвержденным фейком (verified fake). Про это тоже можно почитать.
Есть идея о контрактных тестах (contract tests), когда тестируется не столько интеграция, сколько конкретное (известное вам) поведение, которое ожидается от сторонней организации. Особенно если вы имеете дело с сторонней организацией, у которой есть ошибки.
Было бы интересно написать тест, предполагающий, что ошибка все еще существует. Если возникнут изменения, то вы узнаете об этом.
Наконец, есть очень крутая библиотека VCR.py. Это своего рода альтернатива для мок-объектов и патчей в модульных тестах или в подходе с использованием мок-объектов. Она позволит вам запускать тесты на реальном API с записью ответов. В следующий раз, когда вы запустите данные тесты, она воспроизведет те же ответы. Возможно, это очень изящное решение всей этой проблемы.
Заключение
Небольшое резюме. Что я предлагаю: вам лучше отказаться от мок-объектов. Что делать вместо них. Нужно сделать адаптер, явную оболочку, которая представляет собой внешнюю зависимость. Это будет изящно, с хорошим API, который хорошо смотрится в вашем коде.
Вместо создания мок-объектов для него, лучше написать свой фейк. Например, фейковый класс, который притворяется реальной внешней организацией. Это может быть обычная коллекция в памяти.
Далее можно попробовать инъекцию зависимости вместо unittest.patch
, подменяющего объекты. Возможно, ваши тесты станут более читаемыми.
Наконец, можно подумать над интеграционными тестами как главном способе тестирования адаптеров.
Зачем мы все это делаем. Потому что это даст нам более изящные, более удобные в обслуживании тесты. Тесты, которые легче читать и писать, более надежные тесты. Они будут влиять на структуру кода. Это поможет вам подумать о том, как изолировать внешние зависимости, убедиться в том, что они маленькие, управляемые и готовы к использованию.
Не забываем о принудительном ослаблении связей между элементами системы (decoupling). Вам действительно придется отделить бизнес-логику, ядро приложения, то, что действительно волнует ваших покупателей, от деталей низкоуровневой инфраструктуры.
Не потому, что одно обязательно важнее другого. Работать должно все. Но это позволит им меняться с разной скоростью. Я могу изменить API без изменения бизнес-логики, я могу изменить бизнес-объект, не меняя API.
Надеюсь, вам понравилось. Большое спасибо за терпение. Спасибо, что смотрели выступления из этой странной коронавирусной версии PyCon 2020. Еще больше можно узнать на cosmicpython.