Мы разрабатываем продукт, который помогает оценивать рыночную стоимость товара, будь то автомобиль, квартира или холодильник. Мы часто пишем тесты, и в этой статье я расскажу, как разные подходы к юнит-тестированию влияют на качество тестов, когда они помогают проекту, а когда — мешают, и почему само по себе наличие тестов ещё не гарантирует пользы. Статья будет полезна разработчикам, тимлидам и всем, кто пишет юнит-тесты и поддерживает код в долгоживущих проектах.
Привет, Хабр! Меня зовут Владимир, я 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-канал, там интересно!
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Пишем быстрые UI-автотесты без флаков, стендов и боли: изоляционный подход в CI/CD | 0 | 14.13 | 29-04-2026 |
| 2 | Property-based testing на практике: как находить баги, о которых вы не подумали | 0 | 10.29 | 20-06-2026 |
| 3 | Playwright не спасает от флапающих тестов: разбираемся, как он ждёт на самом деле | 0 | 8.49 | 28-07-2026 |
| 4 | Почему автотесты пропускают изменения в API и как это исправить с Pydantic | 0 | 5.93 | 26-04-2026 |
| 5 | Параллелизация pytest: от xdist до Kubernetes | 0 | 8.8 | 28-01-2026 |
| 6 | Архитектура тестового фреймворка | 0 | 9.5 | 27-04-2026 |
| 7 | Unit Testing: Catching Speed Changes | 0 | 6.05 | 05-03-2026 |
| 8 | Параметризация Pytest: когда файловая коллекция становится тест-кейсами | 0 | 11.85 | 21-03-2026 |
| 9 | Универсальный автогенератор тестов API на базе Schemathesis | 0 | 3.6 | 04-01-2026 |
| 10 | Как тестировать внешние API в Python: от DI-мока до настоящего HTTP-сервера | 0 | 9.72 | 24-05-2026 |