Привет, Хабр! На связи участница профессионального сообщества NTA Александра Грушина.

Поговорим о важности написания тестов к своему коду, о магии подхода test-driven development. Я расскажу о своём пути: от первого знакомства с концепцией TDD до умелого использования инструментов тестирования на Java (Junit 5 + Mockito).

Как я пришла к разработке через тестирование

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

Разработка через тестирование. Основные принципы

Итак, я убедилась на своём опыте, что наличие в коде тестов сильно упрощает жизнь мне как разработчику. Поэтому я придерживаюсь правила: чтобы не упустить ничего, что нужно протестировать, я пишу сперва тесты, затем код. По-научному это называется test-driven development. Давайте разберёмся, что же это такое.

TDD (test-driven development) — разработка через тестирование. Согласно принципам TDD, код реализуется по следующей схеме:

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

  2. Далее я пишу код непосредственно для выполнения цели. Тут первоочередной задачей становится рабочий тест. На этом этапе я отключаю своё эстетство и на время откладываю в сторону старания по написанию чистого кода. Бывает, что сразу видится решение, способное убить двух зайцев: обеспечить работоспособность и чистоту коду. Но если такого решения нет, то приоритетная задача для меня — успешно пройденный тест.

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

Вот краткая схема подхода TDD:

В дополнение к основным принципам TDD я хочу поделиться своими best practices тестирования:

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

Тезис второй — тест станет более понятным вам самому, если он будет хорошо структурирован. Каждый тест условно разделен на три этапа: подготовка (setup), действие (act) и проверка (verify). Для удобства в своей IDE я сохранила hot-key шаблона теста, который выглядит так:

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

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

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

Причины выбора JUnit и Mockito

Можно перечислить более десятка качественных фреймворков для тестирования на Java, но, как я уже говорила, я придерживаюсь концепции TDD, и мой выбор пал на JUnit как наиболее удобный и оптимальный вариант для разработки через тестирование. Больше всего мне понравился JUnit тем, что его концепция как раз прекрасно подходит под концепцию TDD, каждый тест на JUnit — это отдельная программа, поэтому каждый тест имеет свою точку входа. К тому же JUnit располагает довольно гибкой настройкой тестов. На данный момент я использую JUnit 5, который принципиально отличается от так же широко применяемых JUnit 4 и JUnit 3. Главное отличие состоит в самом механизме тестирования.

Механизм тестирования — это компонент, отвечающий за выполнение тестов и представление результатов. В JUnit 4 для выполнения тестов в классе используются аннотации типа @RunWith. С их помощью вызывается так называемый раннер — класс, определяющий способ выполнения тестов.

В JUnit 5 архитектура была переосмыслена, а традиционные раннеры были заменены более гибким и модульным подходом, так называемыми «тестовыми движками». Они отделяют процесс выполнения теста от самой среды тестирования, что дает несколько преимуществ:

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

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

  3. Кастомизация. Разработчики могут создавать собственные механизмы тестирования для удовлетворения конкретных требований к тестированию или для интеграции с различными инструментами и платформами.

  4. Интеграция. Механизмы тестирования могут интегрироваться с различными инструментами сборки, средами разработки и системами непрерывной интеграции, что упрощает внедрение JUnit 5 в различные рабочие процессы разработки.

В JUnit 5 есть несколько встроенных механизмов тестирования, каждый из которых предназначен для определённых целей. По умолчанию предусмотрены два основных механизма:

  • JUnit Vintage: этот движок предназначен для запуска тестов JUnit 3 и JUnit 4, обеспечивая обратную совместимость со старым тестовым кодом.

  • JUnit Jupiter: это новый движок, представленный в JUnit 5. Он поддерживает новую модель программирования и функции JUnit 5, такие как вложенные тестовые классы, параметризованные тесты, динамические тесты и многое другое.

Гибкая настройка тестов заключается в наличии аннотаций @BeforeEach и @AfterEach. Эти аннотации выполняют методы до и после каждого метода тестирования, обеспечивая чистую настройку и демонтаж для каждого теста. Они полезны для подготовки общих ресурсов или поддержания согласованного состояния между тестами.

В JUnit 5 используется два типа методов проверки тестируемого кода: assert и assumption.

Методы assert используются для явной проверки того, выполняется ли ожидаемое условие. Если условие оценивается как ложное, тест считается не пройденным и возникает assertion error. Это особенно полезно для проверки того, соответствует ли фактический результат метода или фрагмента кода ожидаемому результату.

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

Итак, ключевые различия между assert и assumption:

  • Поведение при failure. Когда assert не выполнено, оно напрямую приводит к сбою теста и сообщает об assertion error. Напротив, если assumption не выполнено, тест не помечается как проваленный, а просто прерывается.

  • Воздействие. Assert используются для проверки правильности поведения кода. Если assert не выполнен, это указывает на ошибку или неожиданное поведение в коде. Assumption используются для настройки условий для запуска теста, а неудавшееся assumption обычно указывает на то, что тест не применим из‑за неправильного окружения или несоблюдения определённых условий.

Я использую методы assert, если хочу убедиться, что определённое условие выполняется во время работы теста. Это полезно для проверки правильности моего кода и выявления непредвиденного поведения.

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

В заключение: и методы assert, и методы assumption являются важными элементами в наборе инструментов модульного тестирования при использовании JUnit 5. Они служат разным целям и подходят для разных сценариев тестирования. Понимая их различия и правильно их используя, можно писать более надёжные и эффективные модульные тесты для своих проектов на Java.

Mockito — тоже фреймворк для тестирования на Java. Он уникален и популярен благодаря своей системе заглушек. Кто хоть раз писал тесты, сталкивался с ситуацией, когда нужно было создать экземпляры классов, необходимых для работы. И поведение этих классов должно быть простым и полностью предсказуемым. Именно они и называются заглушками. Для их получения можно создавать альтернативные тестовые реализации интерфейсов, наследовать классы с переопределением функциональности и т. д. Но такой путь неудобен и избыточен. И тут на помощь приходит Mockito. Mock-объект — это объект класса, в котором заданы все параметры по умолчанию. Поведение такого экземпляра всегда можно переопределить. Помимо прочего, mock можно получить и для тех классов, объект которых не так просто создать.

Главная причина выбора Junit 5 и Mockito состоит в том, что такой стек без преувеличения удовлетворяет все потребности, которые могут возникнуть в процессе тестирования и разработки через тестирование.

Лучшие примеры из использования JUnit 5 и Mockito

Создав общее представление о том, что такое JUnit 5 и Mockito, теперь я расскажу о примерах его использования в собственной практике.

Одна из частей проекта, над которым я работала, состояла в модернизации кода open-source-платформы Camunda (платформа автоматизации рабочих процессов) под нужды компании.

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

В CamundaProcessHistoryEventHandler вызывается метод sendMessage класса KafkaHistoryMessageProducer.

А вот тесты к этому коду:

Как видите, я использовала аннотацию BeforeEach (которая говорит нам о том, какие действия должны быть выполнены перед самим тестом), а также создала mock-объект. В данном примере тест проверяется с помощью метода verify — инструмента библиотеки Mockito. Методы семейства Mockito.verify() используются, чтобы убедиться, что тестируемый класс вызывает методы этих объектов нужное количество раз, в нужном порядке и с нужными параметрами. И это ещё один тип проверки, недоступный в JUnit с его assert и assumption.

Одним из способов определения правильности написанного теста является проверка его падения при изменении кода. Ниже показано, что я изменила данные, передаваемые в метод sendMessage класса KafkaProducer. Теперь я передаю в historyTopic конкретную строку, не переменную.

А здесь тест падает из-за несовпадения данных:

Класс KafkaHistoryMessageProducer интересен тем, что прежде, чем получить итоговой результат, он вызывает четыре метода другого класса (KafkaUtils), инициализируя при этом четыре переменные:

Тестировать такую конструкцию особенно важно, поскольку велика уязвимость из-за вызова большого количества методов. Как же тестировать устроенные таким образом классы? Как можно увидеть ниже, я использовала метод when().thenReturn(). Он применяется для указания возвращаемого значения для вызова метода с заранее заданными параметрами.

Помимо прочего, Mockito позволяет «шпионить» за реальными объектами. Есть так называемый Spy‑объект, который по умолчанию исполняет оригинальное поведение методов объекта. Spy — это примитивный способ перехвата вызовов методов объектов в тестовой среде. Как и Mock, Spy позволяет управлять поведением тестируемых компонентов. Однако, в отличие от Mock, Spy сохраняет реальную реализацию тестируемого объекта, а не заменяет его на свой собственный, эмулирующий объект. Spy работает как наблюдатель (шпион), позволяя разработчику получать доступ к внутренним методам и свойствам объекта во время его работы, а также анализировать данные в режиме реального времени.

Ниже показан довольно сложный код класса CustomProcessEnginePlugin, который расширяет AbstractProcessEnginePlugin (благодаря этому плагину возможна интеграция собственных расширений и модификаций движка процесса).

А вот мой тест к этому коду:

Я использовала Spy для оборачивания CustomProcessEnginePlugin. Такое оборачивание позволяет вызвать метод preInit, который ничего не возвращает. Благодаря spy-объекту, я могу проверить параметры объекта EventHandler, созданного в методе preInit.

Заключение

Я рассказала о том, что такое TDD, о своём опыте в разработке и тестировании на Java c использованием JUnit и Mockito. На своём опыте я убедилась в удобстве разработки через тестирование, познала, так сказать, «дзен» этой практики. Используемый мной стек JUnit 5 и Mockito удобен именно потому, что может закрыть все случаи, которые возникают в процессе как TDD, так и простого покрытия кода тестами. В статье я представила такие примеры:

  • Класс, методы которого состоят из одной строки — вызова метода другого класса. Тестирование с использованием аннотации @BeforeEach, mock‑объекта и метода verify().

  • Класс, метод которого прежде, чем получить итоговой результат, вызывает n методов другого класса, инициализируя при этом n переменных. Тестирование с использованием аннотации @BeforeEach, mock‑объекта, методов when().thenReturn() и verify().

  • Класс, который переопределяет методы родительского класса. Тестирование с использованием аннотации @BeforeEach, mock‑объекта, spy‑объекта, методов doReturn.when(), when().thenReturn(), asserThat() и verify().

Одним из самых необходимых для Java-разработчиков моментов является взаимодействие с Apache Kafka. Kafka используется чуть ли не в каждом проекте на Java; он особенно полезен для организаций, которые стремятся оптимизировать свои процессы обработки данных в условиях высокой нагрузки и требований к надёжности. В каждом классе, который представлен в моих примерах, происходит взаимодействие с Kafka. И тестирование такого взаимодействия особенно важно, ведь очень часто Kafka используется там, где оперативная обработка транзакций и мониторинг рисков критически важны.

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

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

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


  1. nronnie
    10.11.2023 12:50
    +1

    три этапа: подготовка (setup), действие (act) и проверка (verify).

    Обычно про них говорят как о "трёх А": "Arrange", "Act", и "Assert".

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


    1. Vendeetta
      10.11.2023 12:50

      Или ещё Given-When-Then


  1. LeshaRB
    10.11.2023 12:50

    ...


  1. Jeisooo
    10.11.2023 12:50
    +1

    Спасибо за статью!

    Расскажите, в какой момент у вас в команде к работе подключаются тестировщики и как происходит работа с вашими юнит-тестами? Трогают ли они их, или пишут потом свои интеграционные?

    Еще интересно, проверяете ли вы потом юниты на тестовых данных(с переключением environment) или так и оставляете на моках?


    1. nronnie
      10.11.2023 12:50
      +1

      Юнит-тесты вообще "трогать" не надо, (разве что локально на машине разработчика) они должны быть встроены в процесс CI, чтобы "красный" код даже в общую "develop" ветку никогда не попал.


    1. NewTechAudit Автор
      10.11.2023 12:50
      +1

      Добрый день!

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


  1. chaetal
    10.11.2023 12:50
    +1

    Следующий шаг: осознать, что TDD — не про тестирование, а про разработку; и пишутся не тесты, а спецификации.