Привет, Хабр! Меня зовут Владимир, я 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-канал, там интересно!
Комментарии (10)

karrakoliko
20.02.2026 13:41хорошие тесты начинаются с верно выбранной абстракции.
пример с order/shipping кривой, потому что в реальности требование "расчитывать цену доставки нужно корретно по таким то правилам" должен реализовывать компонент вроде
ShippingFeeCalculator, с методом вродеcalculateFee(***)который прекрасным образом покроется тривиальным юнит тестом и не будет ломаться на каждый чих.если уж очень хочется получать цену доставки из Order, то этот калькулятор передается в метод как аргумент, а не в конструктор order. order тупо делегирует, что покрывать действительно нет особого смысла.
если появляется новая реализация расчета доставки (новая реализация интерфейса fee калькулятора) - пишется новая реализация и новый юнит тест для нее, тестировать делегирование не стоит.
если для расчета доставки появляются новые параметры во всех калькуляторах (было
calcFee(order)стало `calcFee(order, moonPosition, managerMood, dollarRate...)` - от это защититься можно только страшно обобщив (типаcalcFee(calcFeeParameters), то есть ценой сложности.не весть какой рефакторинг, но делать так сразу - скорее всего не окупится, и дешевле будет с этим смириться.

KrySeyt Автор
20.02.2026 13:41Правильная абстракция действительно важна, чтобы тест был качественным.
ShippingFeeCalculatorможет иметь зависимости, с помощью которых он будет получать необходимые для расчета данные, но это усложнит тестирование.Также, логика расчета стоимости доставки может быть сложной и её потребуется декомпозировать на несколько компонентов, что тоже может привести к появлению зависимостей у
ShippingFeeCalculator.Что делать с разными видами зависимостей и какая абстракция лучше всего подходит для покрытия юнит-тестами я постарался рассказать в статье.

karrakoliko
20.02.2026 13:41ShippingFeeCalculator может иметь зависимости, с помощью которых он будет получать необходимые для расчета данные, но это усложнит тестирование.
и совершенно точно будет, но это прекрасно, потому что каждая реализация калькулятора будет иметь только свои зависимости нужные ей.
EmsFeeCalculator(emsBase, ...)
RussianPostFeeCalculator(russianPostBase, ...)

wert_lex
20.02.2026 13:41Как писать юнит-тесты, которые не ломаются
Никак. В этом и состоит идея юнит-тестов -проверять соблюдение инвариантов. Инварианты меняются - тесты, как лакмусовая бумажка, меняют цвет (с зеленого на красный, если повезёт)

Andrey_Solomatin
20.02.2026 13:41Определение юнит тестов у автора достаточно широкое: тесты которые можно написать на фреймворке у которого есть unit в названии.
Я предпочитаю разделять юнит тесты и прочие тесты (интеграционные, компонентные, изоляционные). Границы всё равно несколько размыты и для каждого проекта нужно решать самому.
Юнит тесты, это что-то простое, без моков (но с заглушками), без соединений с базой, привязаны к конкретным классам и функциям, в том числе и внутренним, обычно чистым. Фокус тут на куске кода и его покрытие. Их жизненный цикл связан с их юнитом. Поменялся юнит, поменялись тесты, не поменялся, тесты должны продолжать проходить.
В месте где я сейчас работаю, всё остальное зовётся изоляционные тесты. И e2e с моками и тестирование отдельных модулей с моками. Сложность и хрупкость тут частые гости.
Эти две группы тестов требуют разного подхода, чистые юниты очень дешёвые в написании и поддержке и позволят быстро отлавливать часть проблем. Пирамида тестирования она как раз об этом.
pomponchik
Прежде всего, тесты бывают 2 видов:
Для переиспользуемого кода (библиотек, например)
Для «одноразового» кода (веб-сервисы и подобное)
В статье стоило уточнить, что речь именно про второе. Код библиотек нуждается в сильно более полном покрытии тестами. Если кто-то поверит статье и протестирует библиотеку по принципу «нет смысла покрывать юнит-тестами тривиальный код», он с крайне высокой вероятностью столкнется с проблемами в будущем.
Также не совсем ясно, зачем автор вводит новую терминологию, вроде «общительных» тестов. Чем не устраивают классические e2e-тесты?
Ну и в статье из рубрики «как тестировать» я бы ожидал увидеть больше «мяса»: какие бывают метрики тестирования, как писать тестируемый код и так далее.
KrySeyt Автор
Спасибо за критику.
В этой статье рассматривается тестирование в первую очередь веб-приложений. Ситуация с тестированием библиотек действительно немного иная.
Общительные и одиночные юнит-тесты это не новая терминология, а два разных подхода к юнит-тестированию, описанные в статье Unit Test Мартина Фаулера, книге Принципы юнит-тестирования Владимира Хорикова и многих других источниках.
Общительные юнит-тесты не являются заменой e2e тестов и решают другие адачи. В том числе, они не работают с внепроцессными зависимостями.
Метрики тестирования, советы по написанию тестируемого кода, влияние устойчивости тестов к рефакторингу на сложность поддержки проекта и многие другие темы однозначно важны, но их добавление сделало бы эту статью чересчур нагруженной и длинной, поэтому я решил раскрыть их в отдельной статье.