Тестирование в Java. JUnit
Сегодня все большую популярность приобретает test-driven development(TDD), техника разработки ПО, при которой сначала пишется тест на определенный функционал, а затем пишется реализация этого функционала. На практике все, конечно же, не настолько идеально, но в результате код не только написан и протестирован, но тесты как бы неявно задают требования к функционалу, а также показывают пример использования этого функционала.
Итак, техника довольно понятна, но встает вопрос, что использовать для написания этих самых тестов? В этой и других статьях я хотел бы поделиться своим опытом в использовании различных инструментов и техник для тестирования кода в Java.
Ну и начну с, пожалуй, самого известного, а потому и самого используемого фреймворка для тестирования — JUnit. Используется он в двух вариантах JUnit 3 и JUnit 4. Рассмотрю обе версии, так как в старых проектах до сих пор используется 3-я, которая поддерживает Java 1.4.
Я не претендую на автора каких-либо оригинальных идей, и возможно многим все, о чем будет рассказано в статье, знакомо. Но если вам все еще интересно, то добро пожаловать под кат.
JUnit 3
Для создания теста нужно унаследовать тест-класс от TestCase, переопределить методы setUp и tearDown если надо, ну и самое главное — создать тестовые методы(должны начинаться с test). При запуске теста сначала создается экземляр тест-класса(для каждого теста в классе отдельный экземпляр класса), затем выполняется метод setUp, запускается сам тест, ну и в завершение выполняется метод tearDown. Если какой-либо из методов выбрасывает исключение, тест считается провалившимся.
Примечание: тестовые методы должны быть public void, могут быть static.
Сами тесты состоят из выполнения некоторого кода и проверок. Проверки чаще всего выполняются с помощью класса Assert хотя иногда используют ключевое слово assert.
Рассмотрим пример. Есть утилита для работы со строками, есть методы для проверки пустой строки и представления последовательности байт в виде 16-ричной строки:
Напишем для нее тесты, используя JUnit 3. Удобнее всего, на мой взгляд, писать тесты, рассматривая нейкий класс как черный ящик, писать отдельный тест на каждый значимый метод в этом классе, для каждого набора входных параметров какой-то ожидаемый результат. Например, тест для isEmpty метода:
Можно разделить данные и логику теста, перенеся создание данных в метод setUp:
Дополнительные возможности
Кроме того, что было описано, есть еще несколько дополнительных возможностей. Например, можно группировать тесты. Для этого нужно использовать класс TestSuite:
Можно запустить один и тот же тест несколько раз. Для этого используем RepeatedTest:
Наследуя тест-класс от ExceptionTestCase, можно проверить что-либо на выброс исключения:
Как видно из примеров все довольно просто, ничего лишнего, минимум нужный для тестирования(хотя недостает и некоторых нужных вещей).
JUnit 4
Здесь была добавлена поддержка новых возможностей из Java 5, тесты теперь могут быть объявлены с помощью аннотаций. При этом существует обратная совместимость с предыдущей версией фреймворка, практически все рассмотренные выше примеры будут работать и здесь(за исключением RepeatedTest, его нет в новой версии).
Итак, что же поменялось?
Основные аннотации
Рассмотрим тот же пример, но уже используя новые возможности:
Если какой-либо тест по какой-либо серьезной причине нужно отключить(например, этот тест постоянно валится, но его исправление отложено до светлого будущего) его можно зааннотировать @Ignore. Также, если поместить эту аннотацию на класс, то все тесты в этом классе будут отключены.
Правила
Кроме всего вышеперечисленного есть довольно интересная вещь — правила. Правила это некое подобие утилит для тестов, которые добавляют функционал до и после выполнения теста.
Например, есть встроенные правила для задания таймаута для теста(Timeout), для задания ожидаемых исключений(ExpectedException), для работы с временными файлами(TemporaryFolder) и д.р. Для объявления правила необходимо создать public не static поле типа производного от MethodRule и зааннотировать его с помощью Rule.
Также в сети можно найти и другие варианты использования. Например, здесь рассмотрена возможность параллельного запуска теста.
Запускалки
Но и на этом возможности фреймворка не заканчиваются. То, как запускается тест, тоже может быть сконфигурировано с помощью @RunWith. При этом класс, указанный в аннотации должен наследоваться от Runner. Рассмотрим запускалки, идущие в комплекте с самим фреймворком.
JUnit4 — запускалка по умолчанию, как понятно из названия, предназначена для запуска JUnit 4 тестов.
JUnit38ClassRunner предназначен для запуска тестов, написанных с использованием JUnit 3.
SuiteMethod либо AllTests тоже предназначены для запуска JUnit 3 тестов. В отличие от предыдущей запускалки, в эту передается класс со статическим методом suite возвращающим тест(последовательность всех тестов).
Suite — эквивалент предыдущего, только для JUnit 4 тестов. Для настройки запускаемых тестов используется аннотация @SuiteClasses.
Enclosed — то же, что и предыдущий вариант, но вместо настройки с помощью аннотации используются все внутренние классы.
Categories — попытка организовать тесты в категории(группы). Для этого тестам задается категория с помощью @Category, затем настраиваются запускаемые категории тестов в сюите. Это может выглядеть так:
Parameterized — довольно интересная запускалка, позволяет писать параметризированные тесты. Для этого в тест-классе объявляется статический метод возвращающий список данных, которые затем будут использованы в качестве аргументов конструктора класса.
Theories — чем-то схожа с предыдущей, но параметризирует тестовый метод, а не конструктор. Данные помечаются с помощью @DataPoints и @DataPoint, тестовый метод — с помощью Theory. Тест использующий этот функционал будет выглядеть примерно так:
Как и в случае с правилами, в сети можно найти и другие варианты использования. Например, здесь рассмотрена та же возможность паралельного запуска теста, но с использованием запускалок.
Вывод
Это, конечно же, не все, что можно было сказать по JUnit-у, но я старался вкратце и по делу. Как видно, фреймворк достаточно прост в использовании, дополнительных возможностей немного, но есть возможность расширения с помощью правил и запускалок. Но несмотря на все это я все же предпочитаю TestNG с его мощным функционалом, о котором и расскажу в следующей статье.
Unit тесты в Java. Краткое руководство
Каждая выполненная задача в программировании требует тестирования, потому что от ошибок, как известно, никто не застрахован. Зачастую на эту процедуру уходит немало времени, даже в простых задачах у новичков. Вы запускаете приложение, вводите данные для проверки и понимаете, что результат не соответствует ожиданиям. Затем вы начинаете выяснять, на каком же этапе произошла ошибка, все это у вас отнимает драгоценные минуты, которые вы могли бы потратить на разработку нового функционала. А что если ваше приложение большое и в нем много зависящих друг от друга модулей, классов и компонент, и ваша задача — изменить поведение существующего кода, при этом не повредив старое.
В чем смысл Unit-тестов?
Именно для этого придумали unit тесты, которые дают возможность автоматизировать проверку приложения. Но ведь на написание тестов тоже уходит время — возразите вы и будете правы. Дело в том, что с ростом вашего приложения это время будет сокращаться, а без тестов ситуация обратная: чем сложнее становится приложение, тем более трудоемким будет его изменение и процесс отслеживания ошибок. К тому же, не будем забывать, что интеграционное тестирование, которым пользуется большинство «смертных» программистов, требует запуска приложения, а, как известно из основ программирования Java, в Java развертывание и сборка — процесс не самый быстрый. Помнится мне один банковский проект, в котором я тратил на это 15 минут, но не будем о грустном.
Для того, чтобы проникнуться данной концепцией, предлагаю почитать об экстремальном программировании. А пока, давайте рассмотрим, какие инструменты нам предлагает Java для решения этой проблемы. Наиболее популярные — JUnit и TestNg, и речь сегодня пойдет о первом. Он является простым и гибким фреймворком для тестирования.
Рекомендуем курс по теме
JUnit аннотации и их описание
Пример Unit теста. Для того, чтобы подключить JUnit, нам необходимо добавить dependency в pom.xml, на момент написания статьи самая свежая версия 4.12:
Затем представим, для простоты понимания, что у нас есть класс калькулятор, который может прибавлять и вычитать:
Unit тест для такого класса будет выглядеть так:
Размещаться данный класс должен в папке test, которая специально создана для хранения тестовых классов. Название класса должно соответствовать одной из масок: *Test, Test*, *TestCase для того, чтобы maven surefire plugin смог найти ваш тестовый класс. Методы должны иметь возвращаемый тип void в сигнатуре и аннотацию @Test, которая определяет, что метод является тестовым. Аннотация @Beforeуказываетна то, что метод будет выполняться перед каждым тестируемым методом. Также для проверки результата используется специальный проверяемый метод assertEquals, который в нашем случае может принимать сообщение, которое будет показываться при несоответствии фактического и ожидаемого результата, затем второй параметр – фактический результат и третий ожидаемый результат.
Далее открываем терминал, перейдем в папку с нашим проектом и выполним mvn test:
По выводу лога консоли видно, что тесты прошли успешно. Если же мы изменим знак в методе add c + на *, то тест будет failed и лог будет выглядеть так:
Таким образом, мы сразу видим, какой тест у нас не прошел проверку, и можем начать отладку с нужной точки.
Рекомендуем курс по теме
В следующей таблице приведен обзор имеющихся в аннотации JUnit 4.x.
Аннотация | Описание |
@Test public void method() public void method() public void method() public static void method() public static void method() | Аннотация @Test определяет что метод method() является тестовым. |
@Before | Аннотация @Before указывает на то, что метод будет выполнятся перед каждым тестируемым методом @Test. |
@After | Аннотация @After указываетна то что метод будет выполнятся после каждого тестируемого метода @Test |
@BeforeClass | Аннотация @BeforeClass указывает на то, что метод будет выполнятся в начале всех тестов, а точней в момент запуска тестов(перед всеми тестами @Test). |
@AfterClass | Аннотация @AfterClass указывает на то, что метод будет выполнятся после всех тестов. |
@Ignore | Аннотация @Ignore говорит, что метод будет проигнорирован в момент проведения тестирования. |
@Test (expected = Exception.class) | (expected = Exception.class) — указывает на то, что в данном тестовом методе вы преднамеренно ожидается Exception. |
@Test (timeout=1000) | (timeout=1000) — указывает, что тестируемый метод не должен занимать больше чем 1 секунду. |
Проверяемые методы (основные)
Метод | Описание |
fail(String) | Указывает на то что бы тестовый метод завалился при этом выводя текстовое сообщение. |
assertTrue([message], boolean condition) | Проверяет, что логическое условие истинно. |
assertsEquals([String message], expected, actual) | Проверяет, что два значения совпадают. Для массивов проверяются ссылки, а не содержание массивов. |
assertNull([message], object) | Проверяет, что объект является пустым null. |
assertNotNull([message], object) | Проверяет, что объект не является пустым null. |
assertSame([String], expected, actual) | Проверяет, что обе переменные относятся к одному объекту. |
assertNotSame([String], expected, actual) | Проверяет, что обе переменные относятся к разным объектам. |
Также я прикрепил пример с ООП моделью компании, в которой подсчитываются затраты на зарплату для сотрудников по компании и департаменту. Основные методы покрыты unit тестами, код размещен тут:
Пишем JUnit тесты на Java
После написания программы хороший программист покрывает свой код тестами, дабы убедиться, что методы работают так, как он планировал.
Тесты бывают нескольких видов:
Сегодня нас интересуют юнит тесты. Де-факто фреймворком для написания юнит тестов на языке Java является JUnit.
Я сейчас не буду расписывать процент проектов, которые используют JUnit, историю создания, популярность в Интернете. Вместо этого я предлагаю сразу начать писать код.
Для того, чтобы начать писать тесты — нам нужен код, который мы будем этими тестами покрывать. Предлагаю создать простой Maven проект и написать в нем небольшой сервис. Я буду все делать в Intellij idea, но, как я говорю каждый раз: выбор среды программирования не имеет значения. Главное, чтобы Вам было удобно и понятно. Шаги по созданию проекта следующие: File->New->Project. Далее будет окно с выбором типа проекта:
создание нового Maven проекта в среде intellij idea
Выбираем Maven и нажимаем Next. Далее заполняем groupId и artifactId значениями com.javamaster и junit_example соответственно.
JUnit — это сторонняя библиотека и ее нужно подключить. Просто добавим зависимость в pom.xml. В результате наш мавен файл стал:
Перед тестами я написал небольшой класс UsersService, который и выступит в качестве кода, который мы попытаемся покрыть тестами.
Код выше — очень простой пример, который не нуждается в дополнительных объяснениях. Если же он Вам по какой-то причине не понятен или очень сложный — я настоятельно советую ознакомиться со статьями Java для новичка.
Теперь приступим к написанию самих тестов.
JUnit тест — это простой класс java, методы которого помечены аннотацией @Test. Как правило, один метод отвечает за тестирование одного кейса. В библиотеке также предусмотрены и другие аннотации которые применяют для предварительной подготовки к тестированию: @BeforeClass, @Before, @AfterClass, @After. Их примеры я тоже покажу в коде. Также, в библиотеке есть готовый класс Assert, с помощью которого можно проводить сравнения фактических значений с ожидающими.
Теперь, создадим класс UsersServiceTest в директории src/main/test/java. Если Вы создавали проект строго по инструкции выше — эта директория должна быть. Но, часто такое бывает, что она отсутствует. В этом нет ничего страшного. Просто создайте ее руками. Структура проекта должна быть примерно как на изображении ниже.
структура проекта
Теперь сам код теста:
Как видно по коду выше, первое, что я сделал — добавил необходимые мне классы и произвел предварительную настройку перед тестом. Я добавил переменную UsersService, ведь именно этот класс я и планирую протестировать. Далее я добавил
Аннотация Rule в JUnit 4 — это компонент, который перехватывает вызовы тестового метода и позволяет нам что-то делать до запуска тестового метода и после запуска тестового метода.
Класс ExpectedException — это тоже из библиотеки JUnit. Правило ExpectedException позволяет вам проверить, что ваш код вызывает конкретное исключение. А если хотите проще: используйте конструкцию выше, для «отлова» нужных Вам исключительних ситуаций.
Далее идут сами методы, которые тестируют функциональность класса UsersService. Из названий методов видно какой функционал они тестируют.
Иногда, бывает полезно не только настроить предварительные ресурсы перед тестированием, но и очистить некоторые из них. С этой задачей отлично позволяют справиться аннотации @AfterClass и @After. В данном примере им приминения не нашлось, но я создал их для примера.
Для запуска тестов нужно нажать правой кнопкой на классе и выбрать Run. Или запустить тест, нажав на зеленую кнопку в intellij idea. В eclipse механизм запуска должен быть похожим. Причем запускать можно как весь класс тестов, так и каждый тест по отдельности.
В результате успешного запуска и отработки тестов Вы должны увидеть в консоли примерно такой результат:
результат успешно отработанных тестов
Цель тестов — выявить ошибки в программе на ранних стадиях, когда код еще не успел уйти в продакшин. Помните, не нужно подстраивать тесты под функционал или, что еще хуже — функционал под тесты. Если определенные тесты не проходят — нужно обратить внимание на бизнес логику, ошибки в коде. Перед этим нужно внимательно ознакомиться с результатом теста и понять, что же все-таки не так.
Возьмем для примера метод isBirthDay. Его функционал и назначение понятны: метод должен возвращать есть ли у пользователя день рождение в зависимости от даты рождения и входной даты. Почему я использовал входную дату вместо использования например текущей даты? Дело в том, что я не знаю когда будут запускаться данные тесты. И может быть такой вариант, что запуск тестов в определенную дату даст нам неверный результат их работы.
Функционал isBirthDay покрывают два теста:
Если бы вместо даты сравнения я использовал бы текущую дату, запуск теста 2 месяца 1 дня — вернул бы true в то время, когда я ожидал совсем другое.
Вернемся к нашему експерименту. Сейчас мы увидим, почему так важно иметь тесты на существующий функционал. Особенно перед тем как потом этот функционал будет изменен. Часто так бывает, что над проектом работал один человек, потом другой, далее проект попадает к другим людям и возникает необходимость изменить уже существующий функционал или его часть.
Допустим, Вас попросили изменить реализацию метода isBirthDay. Возможно, пример примитивный и простой, но в целях учебы вполне себе сойдет.
Вы что-то там делали и написали вот такое решение:
Можете думать, что данный пример я выдумал, но в свое время, у нас был метод в приложении, который определял есть ли день рождения у пользователя. Причем, отрабатывал шедулер, который выбирал из базы данных пользователей, брал дату их рождения и потом отсылал поздравление на почту. Проблема была в том, что пользователей было примерно пол миллиона, а день рождения у них хранился не в очень удобном для java формате. Код поздравлений с днем рождения отрабатывал очень медленно. Программист, который его написал явно был не очень опытным: он брал из базы дату рождения пользователя в виде строки, потом вынимал из строки день и месяц, и сравнивал их с текущим днем и месяцем.
В результате, мы пришли к решению, что код нужно оптимизировать. Это дело поручили одному программисту, который и предложил сравнивать дату рождения пользователя с текущей :). Удивительно, но тестов на тот функционал не было ни до, ни после «оптимизации». Вот он пришел ко мне примерно с таким кодом:
и говорит: «Не понимаю, в базе данных так много пользователей но пока никому не отправилось поздравление». Вот такой вот казус случился однажды с реальным приложением. А были бы тесты — они бы все показали.
Давайте попробуем запустить тесты и посмотреть, что же будет отрабатывать когда мы используем такое решение для наших именинников:
результат запуска с падением тестов
Как видно на изображении выше — тесты очень хорошо справились с поставленной задачей.
У Вас часто будет возникать вопрос: сколько нужно тестов, чтобы убедиться в правильности написанного кода? На это нет конкретного ответа. Старайтесь писать столько тестов, сколько сами считаете нужным. На реальных проектах Вы будете писать столько, на сколько будет хватать времени. Я советую покрывать тестами некоторые подозрительные и опасные места в коде. Особено тщательно нужно покрывать места, где идет работа кода с финансами, округлениями. Как показывает опыт, именно они наиболее часто дают ошибки.
Это все, что касается юнит тестирования и, в частности, написания JUnit тестов на Java.
Передовой опыт тестирования в Java
Чтобы покрытие кода было достаточным, а создание нового функционала и рефакторинг старого проходили без страха что-то сломать, тесты должны быть поддерживаемыми и легко читаемыми. В этой статье я расскажу о множестве приёмов написания юнит- и интеграционных тестов на Java, собранных мной за несколько лет. Я буду опираться на современные технологии: JUnit5, AssertJ, Testcontainers, а также не обойду вниманием Kotlin. Некоторые советы покажутся вам очевидными, другие могут идти вразрез с тем, что вы читали в книгах о разработке ПО и тестировании.
Вкратце
Общие положения
Given, When, Then (Дано, Когда, То)
Тест должен содержать три блока, разделённых пустыми строками. Каждый блок должен быть максимально коротким. Используйте локальные методы для компактности записи.
Given / Дано (ввод): подготовка теста, например, создание данных и конфигурация моков.
When / Когда (действие): вызов тестируемого метода
Then / То (вывод): проверка корректности полученного значения
Используйте префиксы “actual*” и “expected*”
Если вы собираетесь использовать переменные в проверке на совпадение значений, добавьте к этим переменным префиксы “actual” и “expected”. Так вы улучшите читаемость кода и проясните назначение переменных. Кроме того, так их сложнее перепутать при сравнении.
Используйте заданные значения вместо случайных
Избегайте подавать случайные значения на вход тестов. Это может привести к «морганию» тестов, что чертовски сложно отлаживать. Кроме того, увидев в сообщении об ошибке случайное значение, вы не сможете проследить его до того места, где ошибка возникла.
Используйте для всего подряд разные заранее заданные значения. Так вы получите идеально воспроизводимые результаты тестов, а также быстро найдёте нужное место в коде по сообщению об ошибке.
Вы можете записать это ещё короче, используя вспомогательные функции (см. ниже).
Пишите краткие и конкретные тесты
Где можно, используйте вспомогательные функции
Вычленяйте повторяющийся код в локальные функции и давайте им понятные имена. Так ваши тесты будут компактными и легко читаемыми с первого взгляда.
Вспомогательные функции на Kotlin можно реализовать так:
Не злоупотребляйте переменными
Условный рефлекс у программиста — вынести часто используемые значения в переменные.
Увы, это очень перегружает код. Хуже того, увидев значение в сообщении об ошибке, — его будет невозможно проследить до места, где ошибка возникла.
Если вы стараетесь писать тесты максимально компактно (что я, в любом случае, горячо рекомендую), то переиспользуемые значения хорошо видны. Сам код становится более убористым и хорошо читаемым. И, наконец, сообщение об ошибке приведёт вас точно к той строке, где ошибка возникла.
Не расширяйте существующие тесты, чтобы «добавить ещё одну маленькую штучку»
Всегда есть соблазн добавить частный случай к существующему тесту, проверяющему базовую функциональность. Но в результате тесты становятся больше и сложнее для понимания. Частные случаи, раскиданные по большой простыне кода, легко не заметить. Если тест сломался, вы не сразу поймёте, что именно послужило причиной.
Вместо этого напишите новый тест с наглядным названием, из которого сразу будет понятно, какого поведения он ожидает от тестируемого кода. Да, придётся набрать больше букв на клавиатуре (против этого, напомню, хорошо способствуют вспомогательные функции), но зато вы получите простой и понятный тест с предсказуемым результатом. Это, кстати, отличный способ документировать новый функционал.
Проверяйте только то, что хотите протестировать
Думайте о том функционале, который тестируется. Избегайте делать лишние проверки просто потому, что есть такая возможность. Более того, помните о том, что уже проверялось в ранее написанных тестах и не проверяйте это повторно. Тесты должны быть компактными и их ожидаемое поведение должно быть очевидным и лишённым ненужных подробностей.
Предположим, что мы хотим проверить HTTP-ручку, возвращающую список товаров. Наш тестовый набор должен содержать следующие тесты:
3. Ещё пару тестов, проверяющих особые случаи или особую бизнес-логику, например что определённые значения в ответе вычислены корректно. В этом случае нас интересуют только несколько полей из всего JSON-ответа. Тем самым мы своим тестом документируем именно эту специальную логику. Понятно, что ничего кроме этих полей нам здесь не нужно.
Самодостаточные тесты
Не прячьте релевантные параметры (во вспомогательных функциях)
Использовать вспомогательные функции для генерации данных и проверки условий удобно, но их следует вызывать с параметрами. Принимайте на вход параметры для всего, что значимо в рамках теста и должно контролироваться из кода теста. Не заставляйте читателя переходить внутрь вспомогательной функции, чтобы понять смысл теста. Простое правило: смысл теста должен быть понятен при взгляде на сам тест.
Держите тестовые данные внутри самих тестов
Всё должно быть внутри. Велик соблазн перенести часть данных в метод @Before и переиспользовать их оттуда. Но это вынудит читателя скакать туда-сюда по файлу, чтобы понять, что именно тут происходит. Опять же, вспомогательные функции помогут избежать повторений и сделают тесты более понятными.
Используйте композицию вместо наследования
Не выстраивайте сложных иерархий тестовых классов.
«Лучше продублировать код, чем выбрать неправильную абстракцию» (Sandi Metz)
Вместо этого я рекомендую использовать композицию. Напишите маленькие фрагменты кода и классы для каждой задачи, связанной с фикстурами (запустить тестовую базу данных, создать схему, вставить данные, запустить мок-сервер). Переиспользуйте эти запчасти в методе @BeforeAll или через присвоение созданных объектов полям тестового класса. Таким образом, вы сможете собирать каждый новый тестовый класс из этих заготовок, как из деталей Лего. В результате каждый тест будет иметь свой собственный понятный набор фикстур и гарантировать, что в нём не происходит ничего постороннего. Тест становится самодостаточным, потому что содержит в себе всё необходимое.
Прямолинейные тесты — это хорошо. Сравнивайте результат с константами
Не переиспользуйте продакшн-код
Вместо этого при написании тестов думайте в терминах ввода и вывода. Тест подаёт данные на вход и сравнивает вывод с предопределёнными константами. Бóльшую часть времени переиспользование кода не требуется.
Не копируйте бизнес-логику в тесты
Правильным будет решение, при котором actualDTO сравнивается с созданным вручную эталонным объектом с заданными значениями. Это предельно просто, понятно и защищает от потенциальных ошибок.
Если вы не хотите создавать и проверять на совпадение целый эталонный объект, можете проверить дочерний объект или вообще только релевантные тесту свойства объекта.
Не пишите слишком много логики
Напомню, что тестирование касается в основном ввода и вывода. Подавайте на вход данные и проверяйте, что вам вернулось. Нет необходимости писать сложную логику внутри тестов. Если вы вводите в тест циклы и условия, вы делаете его менее понятным и более неустойчивым к ошибкам. Если ваша логика проверки сложна, пользуйтесь многочисленными функциями AssertJ, которые сделают эту работу за вас.
Запускайте тесты в среде, максимально похожей на боевую
Тестируйте максимально полную связку компонентов
Обычно рекомендуется тестировать каждый класс изолированно при помощи моков. У этого подхода, однако, есть и недостатки: таким образом не тестируется взаимодействие классов между собой, и любой рефакторинг общих сущностей сломает все тесты разом, потому что у каждого внутреннего класса свои тесты. Кроме того, если писать тесты для каждого класса, то их будет просто слишком много.
Изолированное юнит-тестирование каждого класса
Вместо этого я рекомендую сосредоточиться на интеграционном тестировании. Под «интеграционным тестированием» я подразумеваю сбор всех классов воедино (как на продакшене) и тестирование всей связки, включая инфраструктурные компоненты (HTTP-сервер, базу данных, бизнес-логику). В этом случае вы тестируете поведение вместо реализации. Такие тесты более аккуратны, близки к реальному миру и устойчивы к рефакторингу внутренних компонентов. В идеале, вам будет достаточно одного класса тестов.
Интеграционное тестирование (= собрать все классы вместе и тестировать связку)
Не используйте in-memory-базы данных для тестов
С in-memory базой вы тестируете не в той среде, где будет работать ваш код
Используя in-memory базу (H2, HSQLDB, Fongo) для тестов, вы жертвуете их достоверностью и рамками применимости. Такие базы данных часто ведут себя иначе и выдают отличающиеся результаты. Такой тест может пройти успешно, но не гарантирует корректной работы приложения на проде. Более того, вы можете запросто оказаться в ситуации, когда вы не можете использовать или протестировать какое-то характерное для вашей базы поведение или фичу, потому в in-memory БД они не реализованы или ведут себя иначе.
Решение: использовать такую же БД, как и в реальной эксплуатации. Замечательная библиотека Testcontainers предоставляет богатый API для Java-приложений, позволяющий управлять контейнерами прямо из кода тестов.
Java/JVM
Совет: добавьте эти аргументы к шаблону конфигурации “JUnit” в IntelliJ IDEA, чтобы не делать это каждый раз при создании нового проекта.
Используйте AssertJ
AssertJ — исключительно мощная и зрелая библиотека, обладающая развитым и безопасным API, а также большим набором функций проверки значений и информативных сообщений об ошибках тестирования. Множество удобных функций проверки избавляет программиста от необходимости описывать комплексную логику в теле тестов, позволяя делать тесты лаконичными. Например:
Избегайте использовать assertTrue() и assertFalse()
Использование простых assertTrue() или assertFalse() приводит к загадочным сообщениям об ошибках тестов:
Используйте вместо них вызовы AssertJ, которые «из коробки» возвращают понятные и информативные сообщения.
Если вам надо проверить boolean-значение, сделайте сообщение более информативным при помощи метода as() AssertJ.
Используйте JUnit5
JUnit5 — превосходная библиотека для (юнит-)тестирования. Она находится в процессе постоянного развития и предоставляет программисту множество полезных возможностей, таких, например, как параметризованные тесты, группировки, условные тесты, контроль жизненного цикла.
Используйте параметризованные тесты
Я горячо рекомендую использовать этот приём по максимуму, поскольку он позволяет тестировать больше кейсов с минимальными трудозатратами.
@MethodSource особенно эффективен в связке с отдельным тестовым объектом, содержащим все нужные параметры и ожидаемые результаты. К сожалению, в Java описание таких структур данных (т.н. POJO) очень громоздки. Поэтому я приведу пример с использованием дата-классов Kotlin.
Группируйте тесты
Читаемые названия тестов при помощи @DisplayName или обратных кавычек в Kotlin
В Kotlin можно использовать имена функций с пробелами внутри, если заключить их в обратные одиночные кавычки. Так вы получите читаемость результатов без избыточности кода.
Имитируйте внешние сервисы
Для тестирования HTTP-клиентов нам необходимо имитировать сервисы, к которым они обращаются. Я часто использую в этих целях MockWebServer из OkHttp. Альтернативами могут служить WireMock или Mockserver из Testcontainers.
Используйте Awaitility для тестирования асинхронного кода
Awaitility — это библиотека для тестирования асинхронного кода. Вы можете указать, сколько раз надо повторять попытки проверки результата перед тем, как признать тест неудачным.
Не надо резолвить DI-зависимости (Spring)
Инициализация DI-фреймворка занимает несколько секунд перед тем, как тесты могут стартовать. Это замедляет цикл обратной связи, особенно на начальном этапе разработки.
Поэтому я стараюсь не использовать DI в интеграционных тестах, а создаю нужные объекты вручную и «провязываю» их между собой. Если вы используете внедрение в конструктор, то это самое простое. Как правило, в своих тестах вы проверяете бизнес-логику, а для этого DI не нужно.
Более того, начиная с версии 2.2, Spring Boot поддерживает ленивую инициализацию бинов, что заметно ускоряет тесты, использующие DI.
Ваш код должен быть тестируемым
Не используйте статический доступ. Никогда
Статический доступ — это антипаттерн. Во-первых, он запутывает зависимости и побочные эффекты, делая весь код сложночитаемым и подверженным неочевидным ошибкам. Во-вторых, статический доступ мешает тестированию. Вы больше не можете заменять объекты, но в тестах вам нужно использовать моки или реальные объекты с другой конфигурацией (например, DAO-объект, указывающий на тестовую базу данных).
Вместо статического доступа к коду положите его в нестатический метод, создайте экземпляр класса и передайте полученный объект в конструктор.
К счастью, DI-фреймворки типа Spring предоставляют инструменты, делающие статический доступ ненужным, автоматически создавая и связывая объекты без нашего участия.
Параметризуйте
Все релевантные части класса должны иметь возможность настройки со стороны теста. Такие настройки можно передавать в конструктор класса.
Представьте, например, что ваш DAO имеет фиксированный лимит в 1000 объектов на запрос. Чтобы проверить этот лимит, вам надо будет перед тестом добавить в тестовую БД 1001 объект. Используя аргумент конструктора, вы можете сделать это значение настраиваемым: в продакшене оставить 1000, в тестировании сократить до 2. Таким образом, чтобы проверить работу лимита вам будет достаточно добавить в тестовую БД всего 3 записи.
Используйте внедрение в конструктор
Внедрение полей — зло, оно ведёт к плохой тестируемости кода. Вам необходимо инициализировать DI перед тестами или заниматься стрёмной магией рефлексий. Поэтому предпочтительно использовать внедрение через конструктор, чтобы легко контролировать зависимые объекты в процессе тестирования.
На Java придётся написать немного лишнего кода:
В Kotlin тоже самое пишется намного лаконичнее:
Не используйте Instant.now() или new Date()
Не надо получать текущее время вызовами Instant.now() или new Date() в продакшн-коде, если вы хотите тестировать это поведение.
Проблема в том, что полученное время не может контролироваться со стороны теста. Вы не сможете сравнить полученный результат с конкретным значением, потому что он всё время разный. Вместо этого используйте класс Clock из Java.
Разделяйте асинхронное выполнение и собственно логику
Тестирование асинхронного кода — непростая штука. Библиотеки типа Awaitility оказывают большую помощь, но процесс всё равно запутан, и мы можем получить «моргающий» тест. Есть смысл разделять бизнес-логику (обычно синхронную) и асинхронный инфраструктурный код, если такая возможность имеется.
Например, вынеся бизнес-логику в ProductController мы сможем запросто протестировать её синхронно. Вся асинхронная и параллельная логика останутся в ProductScheduler, который можно протестировать изолированно.