Привет, Хабр! Меня зовут Владимир, я Python-разработчик в команде IMV в Авито. Мы разрабатываем продукт, который помогает оценивать рыночную стоимость товара, будь то автомобиль, квартира или холодильник.
Мы часто пишем тесты, и в этой статье я расскажу, как разные подходы к юнит-тестированию влияют на качество тестов, когда они помогают проекту, а когда — мешают, и почему само по себе наличие тестов ещё не гарантирует пользы. Я буду рассказывать на примере Python и pytest, но вся информация актуальна для любого стека технологий.
Статья будет полезна разработчикам, тимлидам и всем, кто пишет юнит-тесты и поддерживает код в долгоживущих проектах.

Содержание
Зачем мы пишем тесты
Чем больше приложение, тем оно сложнее, и систему становится труднее понять и изменить без риска ошибки. Так происходит не только у нас: проекты редко остаются неизменными, они растут со временем, а значит, растёт их сложность. Чтобы добавить новую фичу, починить баг или исправить уязвимость, требуется всё больше ресурсов.
Если проект рассчитан на долгую жизнь, важно обеспечить его устойчивое развитие — чтобы даже спустя время в нём можно было легко разобраться и вносить изменения без лишних временных затрат.
Есть разные способы обеспечить устойчивое развитие проекта, один из них — тестирование. Оно помогает контролировать изменения в коде и вовремя находить ошибки, которые появляются по мере роста системы. Его часто воспринимают как инструмент «здесь и сейчас», но на деле оно работает на будущее проекта. Тесты фиксируют текущее поведение системы и помогают безопасно вносить изменения, не перепроверяя весь проект вручную, а также позволяют быстрее понимать, какое поведение считается корректным даже спустя время.
Но само по себе наличие тестов ещё не гарантирует, что нам будет проще справиться с поддержкой проекта. Тесты — такое же обязательство, как и основной код приложения. Они тоже требуют времени и усилий на поддержку, и чем дольше проект развивается, тем больше ресурсов на это уходит. Чтобы польза от тестов перевешивала их издержки, они должны быть качественными.
Качество тестов состоит из четырёх атрибутов:
защита от багов;
устойчивость к рефакторингу;
быстрая обратная связь;
простота поддержки.
Эти атрибуты зависят в том числе от вида тестов: юнит-тесты имеют быструю обратную связь и их относительно несложно поддерживать, но устойчивость к рефакторингу у них ниже, чем у сервисных и сквозных тестов.
При создании юнит-тестов нужно сконцентрироваться на трёх атрибутах: защите от багов, устойчивости к рефакторингу и простоте поддержки. Обратную связь не берём, потому что она и так очень быстрая.
Хорошие тесты должны находить реальные ошибки в поведении системы, не ломаться при изменении деталей реализации, быть понятными и недорогими в поддержке
Эти атрибуты пригодятся нам дальше, чтобы определить сферу применения юнит-тестов и оценить их качество.
Как тестировать юнит
Юнит-тесты бывают двух видов, которые сильно отличаются друг от друга:
Общительные (Sociable) — юнит тестируется вместе со всеми его зависимостями. Это не означает полное отсутствие моков, мы всё ещё используем их для тестирования наблюдаемого поведения.
Одиночные (Solitary) — юнит тестируется в отрыве от своих зависимостей, вместо них используются моки, которые проверяют корректность взаимодействия с ними.
Рассмотрим на примерах.
Код, который будем тестировать:
class Shipping: def cost_per_km(self, distance_km: int) -> int: return distance_km * 10 def base_fee(self) -> int: return 50 class Order: def __init__(self, shipping: Shipping) -> None: self._shipping = shipping def get_shipping_cost(self, distance_km: int) -> int: base = self._shipping.base_fee() distance_cost = self._shipping.cost_per_km(distance_km) return base + distance_cost
Общительный юнит-тест:
def test_order_sociable() -> None: order = Order(Shipping()) assert order.get_shipping_cost(15) == 200
Одиночные юнит-тесты:
from unittest.mock import create_autospec def test_shop_solitary() -> None: shipping = create_autospec(Shipping) shipping.base_fee.return_value = 50 shipping.cost_per_km.return_value = 150 order = Order(shipping) result = order.get_shipping_cost(15) assert result == 200 shipping.base_fee.assert_called_once() shipping.cost_per_km.assert_called_once_with(15) def test_shipping_base_fee(): shipping = Shipping() assert shipping.base_fee() == 50 def test_shipping_cost_per_km(): shipping = Shipping() assert shipping.cost_per_km(10) == 100
Оба теста — общительный и одиночный — работают и дают некоторую гарантию, что наш код корректен. Но одиночный тест получился сложнее, потому что пришлось создать мок и проверять взаимодействие с ним, а чтобы покрыть тот же объём кода, что и в общительном тесте, нам потребовались дополнительные тесты.
Как думаете, какой из двух типов тестов имеет наилучшие атрибуты качества?
Проведём небольшой рефакторинг и посмотрим, что произойдёт с тестами:
class Shipping: def calculate_cost(self, distance_km: int) -> int: return distance_km * 10 + 50 class Order: def __init__(self, shipping: Shipping) -> None: self._shipping = shipping def get_shipping_cost(self, distance_km: int) -> int: return self._shipping.calculate_cost(distance_km)
Код решает ту же самую задачу и делает это правильно. Изменились только детали реализации, которые не влияют на пользователя.
Давайте посмотрим, что случилось с нашими тестами:
Общительный (sociable) тест отрабатывает без ошибок, потому что наблюдаемое поведение нашего кода всё ещё правильное.
Одиночный тест сломался — он завершается с ошибкой, несмотря на то что наш код работает корректно и решает ту же самую задачу. Изменились только детали решения этой задачи, которые не затрагивают пользователя, но тест сломан.
Разберёмся, почему так произошло.
Одиночные тесты заставляют нас мокать все зависимости юнита и проверять корректность работы с ними, даже если работа с ними не является частью публичного интерфейса. Тем самым мы фиксируем детали реализации, которые не важны для пользователя этого кода и которые могут и будут меняться с течением времени. Это делает тесты хрупкими и усложняет рефакторинг. Чтобы избежать этого, мы должны тестировать только наблюдаемое поведение.
Общее правило такое: юнит-тесты должны проверять «что» делает код, а не «как» он это делает. Как только тест начинает фиксировать детали реализации, он становится хрупким и начинает мешать изменениям.
Это подводит нас к использованию общительных юнит-тестов, но вовсе не означает, что мы должны полностью отказаться от одиночных юнит-тестов.
Общительные тесты почти всегда будут правильным выбором, потому что позволяют тестировать только наблюдаемое поведение без привязки к деталям реализации. Но если требуется протестировать сложный алгоритм, нужно постараться вынести его в отдельный компонент (юнит) с минимальным числом зависимостей. Если зависимости всё же будут, можно рассмотреть возможность покрыть его одиночными тестами: они позволят тщательнее проверить граничные случаи, но сделают тесты более хрупкими.
Для чего использовать юнит-тесты в реальном приложении
В сравнении с другими видами тестов юнит-тесты достаточно хрупкие и покрывают небольшой объём кода, но позволяют проще и тщательнее протестировать отдельный юнит. Такого внимания заслуживает не каждый юнит в вашем коде, а только наиболее важные или сложные — бизнес-логика или сложные алгоритмы.
Желательно, чтобы юнит не имел внепроцессных зависимостей — не работал с СУБД, брокерами сообщений и другими зависимостями, которые представляют собой отдельные процессы операционной системы. Это позволит обойтись без моков, тем самым сделает тесты проще и надёжнее.
Пример с Order и Shipping выше — как раз такой юнит. Это бизнес-логика без внепроцессных зависимостей, поэтому её можно легко протестировать общительными юнит-тестами без моков, сохранив надёжность, простоту поддержки и защиту от багов.
Отделять бизнес-логику и сложные алгоритмы от работы с внепроцессными зависимостями и другой координации — хорошая практика, которая упрощает не только тестирование, но и основной код.
Если приложение имеет слоистую архитектуру, идеальным кандидатом для юнит-тестирования будет самый внутренний, доменный слой. Он содержит всю бизнес-логику приложения и в идеале не имеет внепроцессных зависимостей.
Подробнее про слоистую архитектуру на Python можно узнать в отдельной статье.
Когда не надо использовать юнит-тесты
Тесты, как и основной код, — это обязательство. Они точно также требуют поддержки на протяжении всего жизненного цикла, который совпадает с жизненным циклом основного кода. Поэтому для тестов действуют те же принципы, что и для основного кода:
Мы стараемся решать задачи как можно проще, без лишних усложнений. Наши тесты должны быть устроены также просто и эффективно.
Тесты не должны создавать дополнительный технический долг и мешать вносить изменения в код.
Тесты должны давать реальную защиту от багов.
Тесты не должны быть хрупкими или часто ломаться при изменениях.
Нет смысла покрывать юнит-тестами тривиальный код. Они не дадут достаточно высокой защиты от багов, но будут ломаться, если изменится наблюдаемое поведение этого кода. В итоге потребуется время и силы на их починку. Издержки, которые возникают из-за этих тестов, намного выше той незначительной пользы, которую можно получить от них.
Тривиальный код удобно тестировать сервисными или сквозными тестами: они покрывают большой объём кода за раз и гораздо устойчивее к рефакторингу, потому что меньше привязаны к деталям реализации, а это облегчает их поддержку. К тому же, сервисные и сквозные тесты пишутся в первую очередь для тестирования сценариев, которые выходят за рамки конкретных юнитов. В этом случае тривиальный код не придётся тестировать отдельно — он уже будет покрыт существующими сервисными и сквозными тестами.
Подробнее про сервисные тесты можно прочитать здесь
И, наконец, не стоит покрывать юнит-тестами код, выполняющий координацию. Обычно он несложный, но имеет много зависимостей, в том числе внепроцессных. Такой код связывает компоненты между собой и передаёт данные между ними. Сложной логики в нём нет — вся сложность связана с количеством зависимостей, включая СУБД, брокеры сообщений и другие внешние сервисы.
Пример координации:
class Item: ... class ItemFactory: ... class Postgres: ... class Broker: ... class Transaction: ... class CreateItem: def __init__( self, item_factory: ItemFactory, pg: Postgres, broker: Broker, transaction: Transaction, ) -> None: self.item_factory = item_factory self.pg = pg self.broker = broker self.transaction = transaction def execute(self, name: str, price: int) -> Item: # Координация item = self.item_factory.create_item(name, price) self.pg.save_item(item) self.broker.send_new_item(item) self.transaction.commit() return item
При тестировании координации мы хотим убедиться, что код корректно работает со всеми своими зависимостями. Мы можем проверить это с помощью юнит-тестов, но…
Одиночные юнит-тесты не подойдут, потому что координация почти не содержит логики, зато включает множество зависимостей, работа с которыми обычно относится к деталям реализации. Замена зависимостей моками делает тесты очень хрупкими из-за фиксации большого количества деталей реализации, которые меняются при малейшем рефакторинге. Большое число зависимостей приводит к большому количеству моков и значительно усложняет поддержку тестов.
В теории можно использовать общительные юнит-тесты и тестировать координацию вместе со всеми её зависимостями, но, к сожалению, это решит наши проблемы лишь частично. Дело в том, что значительная часть зависимостей — внепроцессные, и их всё равно приходится заменять моками, иначе тесты перестанут быть юнит-тестами. Помимо фиксации деталей реализации, использование моков повышает сложность поддержки тестов, поскольку моки нужно не только создавать, но и обновлять при изменении используемого интерфейса клиента к внепроцессной зависимости.
Кроме того, тесты с моками не обеспечивают такой же уровень защиты от багов, как тесты с реальными СУБД, брокерами сообщений и другими внепроцессными зависимостями. Например, корректность SQL-запроса к Postgres сложно достоверно подтвердить без подключения к настоящей базе.
К тому же полезно проверять и корректность работы с самим клиентом внепроцессной зависимости. Некоторые из них, например FastStream, предоставляют тестовые инструменты, которые позволяют тестировать работу с клиентом без использования самой внепроцессной зависимости. Однако такие инструменты есть далеко не всегда. Так, корректность работы с SQLAlchemy (ORM) сложно проверить без реальной СУБД. Успешная компиляция и анализ типов не гарантируют, что код работает правильно.
Чтобы избежать этих проблем, координацию лучше тестировать сервисными тестами.
Чтобы превратить общительные юнит-тесты координации в сервисные тесты, достаточно заменить моки на реальные внепроцессные зависимости. Такие тесты проверяют корректность работы с клиентами и обеспечивают значительно более высокую защиту от багов за счёт использования реальных зависимостей. Устойчивость сервисных тестов к рефакторингу также выше, поскольку они меньше привязаны к деталям реализации и используют гораздо меньше моков.

Бывает, что код содержит и бизнес-логику или сложный алгоритм, и выполняет координацию.
Мы можем покрыть такой код тестами, но какой бы тип тестов мы ни выбрали, их качество окажется невысоким.
Общительные юнит-тесты дают хорошую защиту от багов в логике, но слабую — в работе с внепроцессными зависимостями и их клиентами. Из-за большого количества моков такие тесты сложны в поддержке. Их устойчивость к рефакторингу также будет низкой, поскольку моки фиксируют детали реализации, в том числе вызовы конкретных методов клиентов к внепроцессным зависимостям.
Сервисные тесты получаются надёжными и относительно несложными в поддержке. Они дают хорошую защиту от багов в работе со всеми зависимостями, включая внепроцессные, но слабо защищают от багов в логике. Если пытаться проверять логику более тщательно, такие тесты становятся значительно сложнее в поддержке и более хрупкими.
Но и тут есть выход. Прежде чем покрывать такой код тестами, его нужно декомпозировать на логику и координацию. После этого логику можно без труда протестировать юнит-тестами, а координацию — сервисными. Качество и тех и других тестов будет достаточно высоким, чтобы они приносили пользу.
Заключение
В статье мы разобрали, как разные подходы к юнит-тестированию влияют на качество тестов и устойчивость развития проекта. Мы сравнили общительные и одиночные юнит-тесты с точки зрения атрибутов качества и обсудили, почему важно фокусироваться на наблюдаемом поведении, а не деталях реализации.
Обобщим:
для бизнес-логики и сложных алгоритмов лучше всего подходят общительные юнит-тесты;.
для кода координации, работы с внепроцессными зависимостями и тривиального кода лучше использовать сервисные и сквозные тесты;
если в коде смешана логика и координация, его нужно сначала декомпозировать;
правильный выбор типа тестов повышает их устойчивость к рефакторингу и снижает технический долг;
в тестах стоит фокусироваться на наблюдаемом поведении, а не деталях реализации.
И последнее: само по себе наличие тестов ещё не гарантирует пользу — её дают только качественные тесты.
Спасибо, что дочитали до конца! А как вы выбираете тесты для своих проектов? Делитесь мыслями и задавайте вопросы в комментариях!
Узнать больше о задачах, которые решают инженеры AvitoTech, можно по этой ссылке. А вот тут мы собрали весь контент от нашей команды — там вы найдете статьи, подкасты, видео и много чего еще. И заходите в наш TG-канал, там интересно!
