Недостаточно просто написать программу – необходимо так же тестировать и код и приложение в целом. Есть разные типы тестирования, но в этой статье я хочу начать с юнит-тестов (или модульных тестов), которые проверяют работу отдельных частей программы изолированно. Для написания юнит-тестов в джаве используется фреймворк JUnit, и в этой статье я разберу его основы – речь пойдёт о пятой версии.
Во-первых, нужно упомянуть TDD (Test-Driven Development). В этой концепции тесты пишутся не после кода (а такой подход тоже вполне неплохо применяется), а до. То есть сначала обдумываются требования к коду и во всех подробностях описываются в виде тестов. А потом уже пишется код, который все эти тесты должен пройти.
Во-вторых, стоит заметить, что одних юнит-тестов недостаточно, ведь они не тестируют всю программу в целом. Среди других типов есть ещё тесты по нагрузке (Load Testing), тесты безопасности (Security Testing), интеграционные тесты (Integration Testing) для проверки совместной работы разных модулей, и тест всей программы в целом (end-to-end Testing).
Ну и теперь можно перейти к изучению JUnit.
Добавляем JUnit в Java-проект. Первый тест
Как и другие зависимости, эту библиотеку необходимо добавить в pom.xml файл. Подходящую версию можно выбрать в mvn repository. Модуль с пятой версии стал называться junit-jupiter, а не просто junit, как раньше.
Например, если я выбираю версию 5.13.4, то добавляю в файл этот код:
<!-- Source: https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter --><dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.13.4</version> <scope>test</scope></dependency>
И проверить работу можно, протестировав какой-нибудь простой тест. Допустим, у нас есть класс Hello, метод hello() которого возвращает строку «Hello World». Тогда тест этого метода будет таким:
public class HelloTest { @Test public void testHelloWorld() { Hello hello = new Hello(); assertEquals(“Hello World”, hello.hello()); }}
Этот тест при запуске будет выполнен и в IDE будет видна зелёная галочка рядом с названием testHelloWorld(). Если же мы поменяем ожидаемое значение, то тест не пройдёт:
@Test
public void testHelloWorld() {
Hello hello = new Hello();
assertEquals(“Hello World!!!!!”, hello.hello());
}
и мы получим сообщение типа:
Expected : Hello World!!!!!Actual : Hello World
В этом сообщении сразу будет видно, что пошло не так. Какое значение мы ожидали, и какое получили. И исходя из этого можно поменять код (или сам тест, если внезапно там оказалась опечатка, но по TDD тесты уже должны быть идеальными).
В этом примере используется метод assertEquals, который проверяет равенство параметров. Поэтому перейдём сразу к подобным методам.
Утверждения (Assertions)
Все методы находятся в классе org.junit.jupiter.api.Assertions. И чтобы не повторять всё время название класса и писать короче, обычно используют статический импорт.
assertEquals – проверяет, что ожидаемое значение равно фактическому.
У метода много перегрузок, но можно ограничиться двумя параметрами: ожидаемое и фактическое значение. Можно также добавить последним аргументом сообщение об ошибке, а в случае с числами – допустимую погрешность третьим аргументом:
assertEquals(0.33, result, 0.01); //допускаем погрешность до одной сотойassertEquals(“HelloWorld”, result, “Something went wrong with our greeting”); //сообщение об ошибке
assertNotEquals – проверяет, что значения не равны.
assertArrayEquals – сравнивает два массива. Для объектов делает глубокое сравнение (deep equality)
assertTrue / assertFalse – проверяют булево условие.
assertNull / assertNotNull – проверяют значение на null.
assertSame / assertNotSame – проверяют, ссылаются ли переменные на один и тот же объект. То есть метод не сравнивает содержимое объектов, а только ссылку. Используется для проверки кэшей, синглтонов, или методов, которые должны возвращать тот же объект, но модифицированный, без создания нового.
assertThrows – проверяет, что код выбрасывает ожидаемое исключение.
assertThrows(Exception.class, () -> { ...});
assertAll – проверяет несколько действий сразу и убеждается, что ни одно не выбрасывает исключение. Не проверяет результаты, только отсутствие исключений.
assertAll( () -> method1(), () -> method2());
assertTimeout / assertTimeoutPreemptively – проверяют, что код выполняется за заданное время для проверки производительности.
assertTimeout(Duration.ofMillis(100), () -> { ...});
fail – принудительно проваливает тест, если выполнение дошло до нежелательного места.
Предположения (Assumptions) и контроль выполнения тестов
Находятся в классе org.junit.jupiter.api.Assumptions
Основные отличия от утверждений из прошлого раздела в том, что в случае не выполненного предположения тест не падает – он не считается проваленным, а просто отменяется его выполнение. То есть эти предположения решают, запускать вообще тест или нет.
assumeTrue – тест выполняется только если условие true
assumeFalse – тест выполняется, если условие false
assumingThat – выполняет блок кода, только если условие правдиво. Сам тест в противном случае не прерывается.
assumingThat(condition, () -> { // код / assertions});
Когда используются предположения? Если есть зависимость от среды:
assumeTrue(System.getProperty("os.name").contains("Windows"));
Или есть есть какие-то предусловия. Например, нет смысла запускать тест, если нет доступа к каким-то данным (отсутствие данных не всегда значит, что алгоритм не работает).
assumeTrue(user != null);assumeFalse(user == null);
Условные аннотации
С предположениями выше тест запускается, и его выполнение может прерваться, как только встречено не выполненное условие. Но бывает проще вообще не запускать тест, а условие описать в аннотации. Если условие не выполнено, то тест не падает, а просто пропускается.
Можно включать или отключать тесты для операционных систем, версий джавы
@EnabledOnOs(OS.MAC)
@DisabledOnOs(OS.WINDOWS)
@EnabledOnJre(JRE.JAVA_17)
@DisabledOnJre(JRE.JAVA_17)
Также можно смотреть на системные свойства или переменные среды:
@EnabledIfSystemProperty(named = «os.name», matches = «.*Mac.*»)
@EnabledIfEnvironmentVariable(named = «ENV», matches = «test»)
Но аннотации не могут проверить значения данных, поэтому предположения всё же остаются полезными.
Основные аннотации.
Теперь пришло время перейти от методов к аннотациям. В первом примере я уже использовал аннотацию @Test, поэтому с неё и можно начать. Именно благодаря этой аннотации JUnit понимает, что метод нужно запускать как тест. Её необходимо ставить над каждым методом, который должен запускаться, как отдельный тест.
Когда тестов становится много, то не очень удобно их отслеживать по названиям методов. На помощь приходит @DisplayName, которая позволяет задать тесту или тестовому классу более понятное имя. Пример для метода:
@DisplayName("Hello World is really Hello World")@Testvoid testHello() { ...}
Также перед методами или классами можно ставить аннотацию @Tag, которая позволяет разбивать тесты по группам и помечать, например, медленные тесты, чтобы их потом запускать не всегда.
@Tag("slow")@Testvoid longTest() { ...}
И потом можно запускать или отключать тесты только определённых групп на основе этой аннотации:
mvn test -Dgroups=fastmvn test -DexcludedGroups=slow
Выполнение конкретного теста можно отключить аннотацией @Disabled.
Повторение тестов с @RepeatedTest
Иногда требуется запустить тесты несколько раз, чтобы удостовериться в стабильности поведения. Для этого существует аннотация @RepeatedTest.
В методе можно получить информацию о текущей итерации или общем количестве итераций из объекта RepetitionInfo:
@RepeatedTest(5)void test(RepetitionInfo info) { int current = info.getCurrentRepetition(); int total = info.getTotalRepetitions();}
Запуск тестов с параметрами @ParameterizedTest
Если прошлая аннотация запускает тесты в одном и том же виде, то @ParameterizedTest позволяет запускать один тест несколько раз с разными входными данными.
@ParameterizedTest@ValueSource(ints = {1, 2, 3, 4})void testDeposit(int number) { System.out.println(number); ...}
В этом примере источник данных показан после @ValueSource: здесь могут быть примитивные значения, строки или классы, но всего один параметр.
Следующий вариант – @EnumSource:
@EnumSource(value = DayOfWeek.class)
Можно фильтровать данные, например выбирая значения в массиве после names:
@EnumSource( value = DayOfWeek.class, names = {"MONDAY", "FRIDAY"})
Или наоборот исключить эти значения из теста:
@EnumSource( value = DayOfWeek.class, names = {"MONDAY", "FRIDAY"}, mode = EnumSource.Mode.EXCLUDE)
Любителям регулярных выражений тут тоже можно будет развлечься:
@EnumSource( value = DayOfWeek.class, mode = EnumSource.Mode.MATCH_ALL, names = ".*DAY")
Больше параметров за раз позволяет задавать @CsvSource:
@ParameterizedTest@CsvSource({"1, First", "2, Second", "3, Third"})void test(double number, String ordinal) { ...}
Чтобы не прописывать все варианты в массиве в самой аннотации, можно просто указать источником файл:
@CsvFileSource(resources = «/data.csv»)
И можно указывать разделитель в параметре delimiter:
@CsvSource(value = {«1;First»}, delimiter = ‘;’)
JUnit автоматически приводит типы, поэтому нужно, чтобы данные в csv были совместимы с типами в сигнатуре метода.
Порядок выполнения тестов и @TestMethodOrder
Мы также можем контролировать порядок выполнения тестов. Для этого необходимо над классом поставить аннотацию @TestMethodOrder, которая задаёт правило, по которому JUnit должен определять порядок запуска тестов в классе.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)class CalculatorTest { ...}
Мы задали правило MethodOrderer.OrderAnnotation.class, с которым над методами можно задавать порядок с помощью @Order. Это полезно, если тесты работают над одним и тем же объектом, и порядок выполнения влияет на результат. Например, мы выполняем арифметические операции, или сначала заполняем объект, а потом с ним работаем.
@Order(1)@Testvoid testPlus() { ...}@Order(2)@Testvoid testMinus() { ...}
Другие стратегии MethodOrderer:
MethodOrderer.MethodName.class – сортирует тесты по имени метода по алфавиту.
MethodOrderer.DisplayName – сортирует тесты по заданному имени в @DisplayName по алфавиту. Если имя не задано, то берётся имя метода.
MethodOrderer.Random.class – случайный порядок
Вложенные группы методов с @Nested
@Nested помечает внутренний класс как вложенную группу тестов и позволяет логически объединять тесты по сценарию, состоянию или условию. Например, в калькуляторе можно разделить арифметические операции с тригонометрическими и с тестами на сохранение переменных в память.
class CalculatorTest { @Nested class SimpleArithmeticsTest { ... } @Nested class TrigonometricTest { ... }}
При запуске родительского класса, тесты из всех вложенных классов тоже будут выполняться. В отчёте они показываются как подгруппа тестов.
Таймауты: @Timeout и assertTimeout
Ограничить время выполнения теста можно с помощью аннотации @Timeout. Прописываем значение и единицу измерения.
@Timeout(value = 500, unit = TimeUnit.MILLISECONDS)@Testvoid test() { ...}
Если поставить аннотацию на класс, то ограничение будет применяться к каждому тесту, а не на общее время выполнения.
Можно ограничить не весь метод, а только блок кода. Для этого подойдёт assertTimeout: первым аргументом передаём длительность, а во втором – выполняемый код:
assertTimeout(Duration.ofMillis(500), () -> { Thread.sleep(200);});
Если время превышено, то получаем ошибку Execution exceeded timeout
Теперь можно перейти к более продвинутым концепциям.
Жизненный цикл тестов: Before / After
Жизненный цикл — это порядок, в котором JUnit создаёт тест, вызывает методы и выполняет тесты. Сначала в этом цикле создаётся сам экземпляр класса, потом вызову коллбэков, и наконец, вызываются тестовые методы.
Lifecycle-аннотации позволяют выполнять код до и после тестов — то есть относятся ко второму этапу цикла.
@BeforeAll выполняется один раз перед всеми тестами, например, для инициализации или получения ресурсов.
@AfterAll выполняется один раз после всех тестов, например, для освобождения ресурсов.
@BeforeEach выполняется перед каждым тестом, например, для сброса состояния — чтобы объект был «чистым» перед каждым тестом.
@AfterEach выполняется после каждого теста
Можно представить цикл схематично:
@BeforeAll (1 раз)для каждого теста: ├── новый экземпляр тестового класса ├── @BeforeEach ├── @Test ├── @AfterEach@AfterAll (1 раз)
И по идее методы с @BeforeAll и @AfterAll должны быть статичными, так как на момент вызова этих методов экземпляр ещё не создан. Но мы можем немного изменить порядок в цикле и добавить перед заголовком класса аннотацию @TestInstance(TestInstance.Lifecycle.PER_CLASS). Это значит, что теперь всего один экземпляр применяется для всех тестов. То есть цикл меняется на такой:
создаётся ОДИН объект теста@BeforeAllдля каждого теста: ├── @BeforeEach ├── @Test ├── @AfterEach@AfterAll
Тогда методы @BeforeAll и @AfterAll не обязаны быть статичными, но нужно быть аккуратнее с тем, как у всех тестов в классе получается общее состояние. По умолчанию используется Lifecycle.PER_METHOD, и наверно, этот вариант безопаснее.
Параллельное выполнение тестов
Общие объекты для всех тестов с TestInstance.Lifecycle.PER_CLASS могут стать проблемой при параллельном выполнении. Но если такой аннотации нет, и тесты не зависят друг от друга, то можно ускорить процесс тестирования.
Чтобы включить параллельное выполнение, нужно добавить в файл свойств (обычно находится в папке src/test/resources/junit-platform.properties):
junit.jupiter.execution.parallel.enabled = truejunit.jupiter.execution.parallel.config.strategy = dynamic
И тогда можно добавить аннотацию над классом с режимом ExecutionMode.CONCURRENT:
@Execution(ExecutionMode.CONCURRENT)class MyTest {}
Параллельность при этом может нарушить порядок выполнения тестов. Если порядок всё же важен, и аннотацию @Execution хочется оставить явно, то в скобках указываем режим ExecutionMode.SAME_THREAD – это отменяет параллельность.
Dependency Injection в JUnit 5
ParameterResolver, контекст и контекстное хранилище
Чтобы не создавать в каждом тесте объект заново, или хотя бы чтобы не инициализировать его вручную в классе с тестами, мы можем сделать «инъекцию». В JUnit она делается с помощью ресолвера, который говорит, какой объект и как использовать для тестов – нужно реализовать интерфейс ParameterResolver.
Допустим, у нас есть некий класс CustomerObject, в котором хранятся данные о людях: строка и число (подробности нам сейчас не интересны). И чтобы сделать для этого класса ресолвер, реализуем два метода. supportsParameter говорит, действительно ли параметр из контекста нужного класса, а resolveParameter возвращает нужный для тестов объект.
public class CustomerParameterResolver implements ParameterResolver { @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { return parameterContext.getParameter().getType().equals(CustomerObject.class); } @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { return new CustomerObject("Egor", 25); }}
ParameterContext предоставляет информацию о параметре, который нужно внедрить
ExtensionContext даёт доступ к тесту и его окружению. С помощью него можно возвращать разные объекты, например, в зависимости от имени теста:
@Overridepublic Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { String testName = extensionContext.getDisplayName(); if (testName.contains("underage")) { return new CustomerObject("Child", 13); } return new CustomerObject("Adult", 25);}
В этом примере для теста с именем underage возвращаем несовершеннолетний «объект», а иначе – взрослого. Имя берём из контекста с помощью метода getDisplayName.
Кроме имени, мы можем получать тэги (getTags), имя класса (getRequiredTestClass), имя метода (getRequiredTestMethod), сам экземпляр класса тестов, если нужно достать из него какие-то данные (getRequiredTestInstance) и не только. Также можно получить хранилище с помощью метода getStore, но эта тема мне кажется уже немного сложнее.
Главная идея в том, что все у тестов в JUnit есть хранилище (мапа). У каждого ExtensionContext своё хранилище, но в исключительных случаях можно делить данные между ними, чтобы не пересоздавать объект, делить результат вычисления между разными тестами (кэшировать его), или разместить общий ресурс, который потом нужно безопасно освободить.
На практике классы не могут просто лезть друг к другу в данные и нарушать инкапсуляцию. Доступ к общему хранилищу работает только если используется корень (root context). Получаем его с помощью метода getRoot.
ExtensionContext root = context.getRoot();Store store = root.getStore(Namespace.create("root"));
В этом примере в неймспейсе root (не обязательно выбирать именно это имя или даже ограничиваться одним неймспейсом) будут данные, общие для всех тестов и расширений. Но нужно понимать, что это работает только для корня – в остальных случаях у каждого класса свой контекст, своё хранилище, организованное по своим неймспейсам. И обойти это нельзя, даже если у нескольких классов или расширений в контекстах есть неймспейсы с одинаковым названием – они всё равно останутся внутри соответствующего хранилища недоступными извне.
Теперь, когда немного разобрались с ресолверами и контекстом, можно перейти к самим тестам – как теперь эти объекты использовать там.
Внедрение зависимостей в тесты и аннотация @ExtendWith
После создания ресолвера в каждом тесте мы можем ожидать уже инициализированный экземпляр в аргументах.
@ExtendWith(CustomerParameterResolver.class)class CustomerTest { @Test void testCustomer(CustomerObject customer) { assertEquals("Egor", customer.getName()); assertTrue(customer.getAge() > 18); }}
Чтобы эта магия сработала, перед классом нужно добавить аннотацию @ExtendWith, а в скобках указать класс ресолвера.
И тут у меня лично возникло несколько вопросов, которые хочется по порядку разобрать.
Как использовать в тестах несколько ресолверов?
Если мы делаем по ресолверу под каждый класс в проекте, то мы можем столкнуться с такой ситуацией. Решается она с помощью массива – все нужные классы перечисляются в фигурных скобках:
@ExtendWith({ CustomerParameterResolver.class, AnotherParameterResolver.class})class MyTest {}
Можно также повторить аннотацию несколько раз для каждого класса.
@ExtendWith(CustomerParameterResolver.class)@ExtendWith(AnotherParameterResolver.class)class MyTest {}
Тогда любой из объектов соответствующих классов можно добавлять в сигнатуры тестовых методов. JUnit сам выберет подходящий ресолвер для каждого параметра.
Но прописывать каждый раз так все ресолверы может быть утомительно. Как не писать эти аннотации в каждом классе тестов?
Тут можно создать абстрактный класс, который будет родителем всех тестов. В нём один раз подключить необходимые ресолверы, а остальные классы уже писать без всяких аннотаций, но не забывать добавлять наследование.
@ExtendWith({ CustomerParameterResolver.class, AnotherParameterResolver.class})abstract class BaseTest {}
Нужно ли создавать по ресолверу для каждого типа данных? Ведь это может стать утомительным, особенно учитывая, насколько мало логики внутри каждого метода.
Оказывается, что вовсе не обязательно: можно ограничиться одним универсальным ресолвером:
public class UniversalTestDataResolver implements ParameterResolver { @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { Class<?> type = parameterContext.getParameter().getType(); return type == CustomerObject.class || type == AnotherObject.class || type == String.class; } @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { Class<?> type = parameterContext.getParameter().getType(); if (type == CustomerObject.class) { return new CustomerObject("Egor", 25); } if (type == AnotherObject.class) { return new AnotherObject(); } throw new ParameterResolutionException( "Unsupported parameter type: " + type.getName() ); }}
Этот ресолвер сумеет внедрить в тесты сразу объекты двух классов.
К счастью, даже это не обязательно, и в реальных проектах можно использовать уже готовое решение. Например, Mockito, но о нём напишу в следующем конспекте, а на сегодня пора заканчивать.
В этом конспекте я рассмотрел основные аспекты юнит-тестирования джава-приложений с JUnit 5: перечислил утверждения и предположения, основные аннотации, разобрал разбивку тестов по группам и даже внедрение зависимостей.