Привет, Хабр! Меня зовут Владимир, я Python-разработчик в команде IMV в Авито. Мы разрабатываем продукт, который помогает оценивать рыночную стоимость товара, будь то автомобиль, квартира или холодильник. 

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

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

Содержание

Зачем мы пишем тесты

Чем больше приложение, тем оно сложнее, и систему становится труднее понять и изменить без риска ошибки. Так происходит не только у нас: проекты редко остаются неизменными, они растут со временем, а значит, растёт их сложность. Чтобы добавить новую фичу, починить баг или исправить уязвимость, требуется всё больше ресурсов.

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

Есть разные способы обеспечить устойчивое развитие проекта, один из них — тестирование. Оно помогает контролировать изменения в коде и вовремя находить ошибки, которые появляются по мере роста системы. Его часто воспринимают как инструмент «здесь и сейчас», но на деле оно работает на будущее проекта. Тесты фиксируют текущее поведение системы и помогают безопасно вносить изменения, не перепроверяя весь проект вручную, а также позволяют быстрее понимать, какое поведение считается корректным даже спустя время.

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

Качество тестов состоит из четырёх атрибутов:

  • защита от багов;

  • устойчивость к рефакторингу;

  • быстрая обратная связь;

  • простота поддержки.

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

При создании юнит-тестов нужно сконцентрироваться на трёх атрибутах: защите от багов, устойчивости к рефакторингу и простоте поддержки. Обратную связь не берём, потому что она и так очень быстрая. 

Хорошие тесты должны находить реальные ошибки в поведении системы, не ломаться при изменении деталей реализации, быть понятными и недорогими в поддержке

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

Тут еще больше контента

Как тестировать юнит

Юнит-тесты бывают двух видов, которые сильно отличаются друг от друга:

  1. Общительные (Sociable) — юнит тестируется вместе со всеми его зависимостями. Это не означает полное отсутствие моков, мы всё ещё используем их для тестирования наблюдаемого поведения.

  2. Одиночные (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)

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

Давайте посмотрим, что случилось с нашими тестами:

  1. Общительный (sociable) тест отрабатывает без ошибок, потому что наблюдаемое поведение нашего кода всё ещё правильное.

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

Разберёмся, почему так произошло. 

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

Общее правило такое: юнит-тесты должны проверять «что» делает код, а не «как» он это делает. Как только тест начинает фиксировать детали реализации, он становится хрупким и начинает мешать изменениям.

Это подводит нас к использованию общительных юнит-тестов, но вовсе не означает, что мы должны полностью отказаться от одиночных юнит-тестов. 

Общительные тесты почти всегда будут правильным выбором, потому что позволяют тестировать только наблюдаемое поведение без привязки к деталям реализации. Но если требуется протестировать сложный алгоритм, нужно постараться вынести его в отдельный компонент (юнит) с минимальным числом зависимостей. Если зависимости всё же будут, можно рассмотреть возможность покрыть его одиночными тестами: они позволят тщательнее проверить граничные случаи, но сделают тесты более хрупкими.

Для чего использовать юнит-тесты в реальном приложении

В сравнении с другими видами тестов юнит-тесты достаточно хрупкие и покрывают небольшой объём кода, но позволяют проще и тщательнее протестировать отдельный юнит. Такого внимания заслуживает не каждый юнит в вашем коде, а только наиболее важные или сложные — бизнес-логика или сложные алгоритмы. 

Желательно, чтобы юнит не имел внепроцессных зависимостей — не работал с СУБД, брокерами сообщений и другими зависимостями, которые представляют собой отдельные процессы операционной системы. Это позволит обойтись без моков, тем самым сделает тесты проще и надёжнее. 

Пример с Order и Shipping выше — как раз такой юнит. Это бизнес-логика без внепроцессных зависимостей, поэтому её можно легко протестировать общительными юнит-тестами без моков, сохранив надёжность, простоту поддержки и защиту от багов.

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

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

Подробнее про слоистую архитектуру на Python можно узнать в отдельной статье

Когда не надо использовать юнит-тесты

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

  1. Мы стараемся решать задачи как можно проще, без лишних усложнений. Наши тесты должны быть устроены также просто и эффективно.

  2. Тесты не должны создавать дополнительный технический долг и мешать вносить изменения в код.

  3. Тесты должны давать реальную защиту от багов.

  4. Тесты не должны быть хрупкими или часто ломаться при изменениях.

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

Тривиальный код удобно тестировать сервисными или сквозными тестами: они покрывают большой объём кода за раз и гораздо устойчивее к рефакторингу, потому что меньше привязаны к деталям реализации, а это облегчает их поддержку. К тому же, сервисные и сквозные тесты пишутся в первую очередь для тестирования сценариев, которые выходят за рамки конкретных юнитов. В этом случае тривиальный код не придётся тестировать отдельно — он уже будет покрыт существующими сервисными и сквозными тестами.

Подробнее про сервисные тесты можно прочитать здесь

Жми сюда!

И, наконец, не стоит покрывать юнит-тестами код, выполняющий координацию. Обычно он несложный, но имеет много зависимостей, в том числе внепроцессных. Такой код связывает компоненты между собой и передаёт данные между ними. Сложной логики в нём нет — вся сложность связана с количеством зависимостей, включая СУБД, брокеры сообщений и другие внешние сервисы.

Пример координации:

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-канал, там интересно!