Привет! Меня зовут Владимир, я разработчик команды продукта «Сервис персонализации» в SM Lab. В этом посте я хотел бы рассказать (а в комментариях — обсудить) один очень важный и полезный инструмент разработчика — юнит-тесты.

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

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

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

При подготовке материала очень помогла книга Владимира Хорикова (@vkhorikov ) «Принципы юнит-тестирования». Рекомендую ее всем, кто хочет еще глубже погрузиться в эту тему.

Итак, поехали.

Что такое юнит-тестирование и для чего оно нужно

Приведу одно из устоявшихся определений: юнит-тестирование – это процесс, который позволяет проверить работоспособность отдельных частей исходного кода программы.

Зачем мы пишем юнит-тесты?

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

Какими качествами должен обладать юнит-тест

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

По поводу «делать это быстро». В области юнит-тестирования достаточно тяжело определить какие-то границы. Если взять тест, который выполняется за секунду - быстро это или медленно? Всё зависит от того, что это за тест, какие требования к нему. В нашей команде мы опираемся на субъективное восприятие скорости – если, по нашему мнению, тесты проходят быстро – этого достаточно.

Как тесты влияют на разрабатываемые нами продукты 

Давайте взглянем на следующий график:

Горизонтальная ось «Прогресс» — своего рода жизненный путь разрабатываемого продукта во времени, где нулевая точка - начало разработки этой системы. В начале жизни продукта развивать его достаточно просто: еще не приняты неудачные архитектурные решения, нет кода, который нужно поддерживать или рефакторить. Поэтому в начале мы можем увидеть, что разработка без тестов, если мы будем сравнивать с другими кривыми, требует минимального времени.

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

С плохими тестами или без тестов рост времени нелинеен, и иногда даже наступает такой момент, когда проще отказаться от текущей реализации системы и переписать ее заново, чем пытаться в неё что-то внести. Исходя из этого, стоит оценивать покрытие кода тестами с точки зрения его жизненного цикла. Если продукт одноразовый, маленький, короткий, который мы написали и потом через какое-то время он будет не нужен, то и, возможно, уровень покрытия тестами нужен достаточно низкий.

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

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

Как измерить покрытие кода тестами 

Для этого есть две чаще всего используемые метрики – процент покрытия строк (code coverage) и процент покрытия логических ветвей (branch coverage) кода.

Как и с легкостью покрытия кода тестами, это хороший негативный признак, но плохой позитивный. Если покрыто всего десять процентов кода, это хороший признак того, что тестов слишком мало.

Но даже стопроцентное покрытие тестами не гарантирует хорошего качества тестов. И вот почему. Эти метрики не отражают некоторые важные детали, такие как наличие и полноту проверок (asserts). Мы можем написать тест, который не имеет проверок вообще. То есть он будет запускать наш основной код, но по факту не будет иметь никакой ценности. И ещё, эти метрики не оценивают код во внешних библиотеках. Они оценивает только тот код, который написали мы.

Вот короткий пример:

Есть метод, который принимает строку и превращает её в число. Очень простой метод. На него написан простой тест, который передаёт строку со значением «5» и сравнивает число 5 с тем, какое число возвращает метод.

Если мы посмотрим на этот тест и попробуем оценить метрики покрытия, мы увидим, что для этих метрик покрытие будет 100%. Но, к сожалению, если мы заглянем в библиотечный метод parseInt, мы увидим, что на самом деле мы проверили только один из четырех вариантов. Поэтому с одной стороны у нас покрытие 100%, но с другой стороны мы покрыли по факту только 25% кода. Поэтому на эти метрики стоит ориентироваться, но не слепо им доверять. В нашей команде мы ориентируемся на уровень в 80% покрытия кода.

Тестовые двойники

Помощниками в тестировании выступают «тестовые двойники» (test doubles). Их выделяют 5 видов.

Пустышка (Dummy). Такой двойник не содержит поведения и используется в качестве заполнителя параметров. Никогда реально не вызывается.

Подделка (Fake). Это реально написанная реализация, которая имеет более простую логику, чем реальный аналог.

Есть ещё три вида похожих между собой тестовых двойников. Но есть и различия.

Заглушка (Stub) - имеет заранее подготовленные ответы на вызовы методов. Практически не имеет логики.

Шпион (Spy). Более сложная система. Это гибрид реального объекта и мока. Он имеет поведение реального объекта, но может записывать определенную информацию о вызове его методов. Также можно переопределить поведение некоторых методов.

Мок (Mock). Самый сложный из тестовых двойников, но дающий наибольший контроль. Может иметь сложную логику ответов, зависящих от параметров вызовов, их количества, генерировать исключения при вызове методов с неопределенным поведением и имеет другой, полезный для тестирования функционал.

Все тестовые двойники, за исключением fake, в основном создаются с помощью фреймворков, с которыми мы часто работаем. Для Java это Mockito, для Kotlin – MockK, для других языков такие фреймворки тоже есть.

Изоляция и виды зависимостей

Что такое зависимость? Класс чаще всего не существует изолированно в коде. Он использует какие-то другие части программы. Зависимости – это то, что использует класс для своей работы. Это могут быть другие классы, базы данных, файловые системы, сторонние сервисы и прочее.

Зависимости с точки зрения изменяемости делятся на изменяемые – те, состояние которых может изменить тест, например, переменные или база данных, и неизменяемые – например, константа или неизменяемый объект. Зависимости с точки зрения возможности влияния тестов друг на друга делят на совместные и приватные. 

Совместные зависимости (shared). Через такие зависимости тесты могут влиять на результаты друг друга. Например, статические изменяемые поля класса, база данных. Это изменяемые зависимости.

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

Приватные зависимости (private). Это зависимости, не являющиеся совместными. Они могут быть как изменяемыми, так и неизменяемыми. Та же база данных может быть как совместной, так и приватной зависимостью. Совместной она может выступать, когда все тесты работают с одной базой. Приватной зависимостью база данных может выступать в случае, когда для каждого теста поднимается отдельная база данных, например в docker-контейнере. В этом случае тесты не смогут повлиять друг на друга через базу.

Есть два подхода (школы) к пониманию изоляции: так называемые классическая и лондонская школы.

Лондонская школа понимает изоляцию тестируемого кода как изоляцию от его изменяемых зависимостей. Все изменяемые зависимости (совместные и приватные) заменяются на тестовые двойники – «мокируются».

 

В этом подходе есть следующие плюсы:

  • можно разбить взаимосвязанный граф классов на отдельные классы, заменив изменяемые зависимости тестовыми двойниками, и тестировать их независимо.

  • позволяет однозначно определить, что юнит – это класс.

  • в случае падения теста поиск проблемы ограничивается одним классом.

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

Мы в команде чаще всего используем этот подход.

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

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

При таком подходе юнитом является не класс, как в Лондонской школе, а единица поведения, и она часто не ограничивается одним классом. При изменении одного из классов падают тесты и всех зависимых классов. Иногда тяжело понять причину массового падения тестов. Для упрощения отладки стоит чаще запускать юнит-тесты, что поможет понять, какие изменения в коде повлияли на тесты и локализуют поиск.  Это также позволяет понять, какие классы наиболее важные и на какие нужно обратить особое внимание.

Эффективность юнит-тестов

Чтобы формально как-то измерить эффективность наших тестов, мы можем представить ее как произведение четырёх параметров.

  • защита от багов – возможность выявить ошибку, которая зависит от объема выполняемого кода, его сложности и важности.

  • устойчивость к рефакторингу – возможность пережить рефакторинг тестируемого кода без выдачи ошибок, минимальные ложные срабатывания.

  • быстрая обратная связь – быстро выполняемые тесты ускоряют обратную связь.

  • простота поддержки – насколько сложно понять и запустить тест.

Пусть каждый из параметров будет числом от 0 до 1. В соответствии с этим можно понять, что если какой-то из параметров равен нулю, то и, соответственно, вся эффективность, насколько бы высокими ни были другие параметры, будет также равна нулю.

Давайте рассмотрим следующие отношения между поведением тестируемого кода и теста:

В случаях, когда функциональность работает правильно и тест проходит (отрицательное срабатывание) или функциональность работает неправильно и тест не проходит (истинное срабатывание) – это ожидаемое поведение теста. В этом случае можно считать, что тест работает правильно.

Есть ещё и две другие области. Ложное срабатывание - функциональность работает правильно, но тест не проходит. Это значит, что тесты, имеют низкую устойчивость к рефакторингу. Чаще всего это означает, что тесты завязаны на детали имплементации и будут падать при изменении реализации тестируемого кода без изменения функциональности. Низкая защита от багов – случай, когда тест проходит, но функциональность работает неправильно.

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

Сквозные тесты (e2e-тесты) задействуют большой объём кода, проходя через цепочку тестируемых элементов или систем, поэтому имеют хорошую защиту от багов. Такие тесты устойчивы к рефакторингу, так как ориентируются на внешний интерфейс – API, и ничего не знают про детали реализации. Такие тесты достаточно медленные - и с точки зрения написания, и с точки зрения прохождения.

Тривиальные тесты могут быть устойчивы к рефакторингу и иметь быструю обратную связь. Например тест, написанный на getter – метод получения значения приватного поля. Такой тест переживет рефакторинг getter-а (если такой вообще будет) и будет быстро проходить. Но пользы от такого теста не будет.

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

Перед тем как разобраться, на какие из характеристик стоит ориентироваться при написании различных тестов, напомню про всем известную пирамиду тестирования

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

  • проще и быстрее они должны разрабатываться.

  • ниже затраты на поддержку тестов.

  • быстрее скорость прохождения отдельного теста.

Перечисленные факторы влияют на выбор между устойчивостью к багам и быстрой обратной связью.

Устойчивость к рефакторингу желательно держать максимальной, поэтому что это величина достаточно бинарная – тесты либо устойчивы к рефакторингу, либо нет. Защита от багов и быстрая обратная связь – более эластичны, поэтому выбор идет между ними.

Сквозных тестов меньше всего – они довольно медленные и не всегда отличаются простотой в поддержке. Но они задействуют большой объем кода и покрывают в основном критичную функциональность, поэтому у них высокая защита от багов.

Юнит-тестов в основном самое большое количество, поэтому для них важна скорость выполнения.

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

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

Комментарии (15)


  1. LeshaRB
    14.07.2022 11:32

    Аннотация SpringBootTest - считается, что это интеграционный тест, поднимает все приложение

    А что насчет DataJpaTest и WebMvcTest - с одной стороны приложение поднимается, с другой оно урезанное. Это какой тест получается?


    1. Cheverikov_vv Автор
      14.07.2022 12:02
      +1

      Я считаю такие тесты интеграционными, так как фактически тестируется связка классов. Например, на контроллер можно написать юнит-тест без аннотаций Spring, создав его экземпляр с замокированными изменяемыми зависимостями.


  1. nronnie
    14.07.2022 12:21
    +1

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


    1. BugM
      14.07.2022 12:33

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


      1. Cheverikov_vv Автор
        14.07.2022 12:41
        +2

        Об этом как раз вторая часть статьи, которая планируется чуть позже. В том числе о том, что нужно покрывать, а что нет, что есть код, который сложно покрывать тестами и как можно такой код сделать более тестируемым.


      1. bratuha
        14.07.2022 13:02
        +2

        Разработка продукта под юнит-тесты и написание самих тестов равнозначно увеличению объемов разработки в разы. Если бизнес готов тратить время и материальные ресурсы на такое раздувание сроков (и коллективов программистов, как угодно) - пожалуйста, никаких проблем. Если в приоритете MVP, то о юнит-тестах вообще не может идти и речи.

        Глобально, тут два пути: либо проект сразу же строится по принципу TDD, либо внедрение юнит-тестов будет для него слоном в посудной лавке. Вынести отдельные модули (api, взаимодействие с сервисами) в тестируемую часть - пожалуйста. Молиться на "проценты покрытия" - карго-культ.

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

        Вывод в том, что тесты должны оставаться инструментом, а не священной коровой. Инструмент имеет место и назначение. График в заголовке статьи - яркий пример как раз такого раздувания значимости.

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


        1. Cheverikov_vv Автор
          14.07.2022 13:16
          +2

          Спасибо за развернутый комментарий.

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

          Также есть опыт увеличения покрытия тестами уже написанного приложения - это долго и трудно, сопряжено с неизбежным рефакторингом, но вполне реально.

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


  1. nin-jin
    14.07.2022 14:27
    +1

    1. bratuha
      14.07.2022 15:29

      Безусловно полезный контент, пригодится каждому, кто хочет попробовать TDD или смотрит в сторону изучения этой практики.


  1. Sklott
    14.07.2022 15:07

    Не понял противопоставления "Лондонской школы" и "Классической школы". Они отличаются друг от друга только тем, что мы считаем юнитом в данном случае и по идее должны использоваться и те и другие (естественно если это надо в конкретном проекте, а не для галочки) и по сути по мере увеличения размера "юнита" они плавно перерастают в интеграционные тесты. Где провести границу, между юинт и интеграционным тестированием, это большой вопрос, ответ на который зависит от большого числа факторов.


    1. Cheverikov_vv Автор
      14.07.2022 15:29
      +2

      Тут правильнее сказать, что отличаются они подходом к изоляции кода - одна школа изолирует код от изменяемых зависимостей, другая - тесты друг от друга. А размер юнита - это следствие из этого. В начале статьи как раз указаны признаки юнит-теста, один из которых - должен поддерживать изоляцию от другого кода, и если оно не выполняется - это уже не юнит-тест.

      Так как мы используем подход Лондонской школы, где юнит == класс, то тест, который проверяет этот класс с замоканными изменяемыми зависимостями - это юнит-тест. Если мы такие зависимости не мокаем, а используем реальные классы - то уже интеграционный. Для классической школы чуть сложнее, но думаю возможно провести границу.


      1. Sklott
        14.07.2022 15:38

        Ок. Пример для понимания моей точки зрения. Допустим есть 2 класса: A - самодостаточный и у него нет никаких внешних зависимостей и B - зависящий только от A.

        В вашем подходе, если мы тестируем класс B с использованием реального класса A, вы называете это интеграционным тестом. "Классическая школа" называет это юнит тестом.

        Но ни на код самих тестов, ни на необходимость наличия их обоих (если это имеет смысл в рамках проекта) это не никак влияет.

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


        1. nin-jin
          14.07.2022 15:54
          +1

          Классическая школа: всегда писали компонентные тесты, но сейчас в моде модульное тестирование, поэтому стали просто называть свои тесты модульными.

          Лондонская школа: верят, что возможность изолировать отдельный модуль - это признак хорошей архитектуры, поэтому тратят 10х времени на тестирование, рефакторинги и звездолёты.

          В какую сам пойдёшь, в какую джуна отправишь?


  1. ruomserg
    15.07.2022 09:50
    +3

    Вопрос тестирования всегда упирается в две вещи. Testable system desing, и стоимость тестирования. Unit тест отвечает на один простой вопрос: «Как работает компонент X, если его окружение (компоненты Y,Z,C,W,etc) работает идеально ?». В некоторых тривиальных случаях окружение отсутствует, и тут юнит-тесты бесконечно полезны. Например — у меня есть код для расчета и проверки контрольной цифры кода EAN13. Он зависит только от документов GS/1 которые не менялись годами, и поэтому его легко тестировать. И это нужно делать, потому что наш остальной код будет стопятьсот раз вызывать эту штуку в разных местах. И я ДЕЙСТВИТЕЛЬНО хочу быть уверен что мы не лажаем в расчете контрольной цифры штрих-кода.

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

    В итоге, если система спроектирована хорошо — в ее основании находятся некоторое количество классов/методов/алгоритмов — которые имеют малое (в идеале, 0) количество внешних зависимостей и не меняют логику своей работы в ходе жизни приложения. Вы их тестируете по юнит-тестами, чтобы точно знать что фундамент не собирается обвалиться. А чем более высокого уровня логику вы тестируете, тем чаще там большие интеграционные тесты, а не мелкие юниты. Если я вижу юнит с большим количеством моков — он скорее всего бесполезен (исключение — юниты которые написаны для поиска каких-то редких и неприятных ошибок, где нужно совершенно определенное состояние окружения чтобы ее стриггерить). Потому что нужно доказывать (и с течением времени — поддерживать!) соответствие идеального окружения созданного моками, и реального окружения в котором будет работать компонент.

    А дальше — будет вопрос о рисках и деньгах… Можно сделать идеально протестированную систему с хорошим покрытием, продуть рынок и пойти под сокращение в результате банкротства. Разумеется, есть и обратные примеры. А дальше это уже вопрос к руководству, бизнес-модели и все такое прочее…


  1. FanToMaS-api
    16.07.2022 15:33

    Хотел попросить более подробно объяснить разницу между интергационным и и сквозными тестами. На мой взгляд, это две вещи одной природы, интеграционный проверяет взаимодецствие с другими компонентами системы, т. е. их совместную работу, в это же время сквозной тест также использует несколько компонентов. Можно ли сказать, что сквозной тест включает в себя (содержит) интеграционный?