Привет, Хабр!
В 2019-2020 годах на одном из проектов я был идейным вдохновителем перехода на JUnit 5. Для проверок мы использовали стандартные ассерты и Hamcrest. Тогда мне казалось, что этого более чем достаточно. Один из наших lead-инженеров предлагал AssertJ как более «модное и молодёжное» решение, но поддержки эта идея не получила. Я был одним из тех, кто выступал против AssertJ. Каюсь, был грешен :)
За последние пару лет, несмотря на менеджерскую позицию, я написал свыше пятисот тестов, и мой подход к тестированию претерпел значительные изменения. В этой статье я постараюсь объяснить, почему AssertJ — это лучшее решение для проверок в тестах, существующее сегодня (год 2022 от Р.X.). Разумеется, всё ниже сказанное — это моё субъективное мнение.
1. Переходите на JUnit 5, если ещё нет
Да, совет «капитанский», но действительно важный. Старые версии должны кануть в Лету, в том числе и JUnit 4. Во всех проектах, где я участвую, явным образом через checkstyle запрещаю использование классов из JUnit 4 (пример тут).
<module name="IllegalImport"> <property name="regexp" value="true"/> <property name="illegalClasses" value="^org\.junit\.Test, ^org\.junit\.jupiter\.api\.Assertions, ^org\.junit\.Test, ^org\.junit\.jupiter\.api\.Assertions\..*"/> <property name="illegalPkgs" value="^org\.hamcrest"/> </module>
Делаю так, потому что полностью убрать JUnit 4 с classpath часто невозможно, например, из-за Testcontainers (см. issue).
2. Структурируйте свои тесты
Я приверженец AAA-подхода: Arrange-Act-Assert. Вы можете также использовать Given-When-Then — принципиально сути это не меняет. Требуйте от разработчиков, чтобы в каждом тесте был ассерт! Для контроля можно (и нужно!) использовать статический анализатор, например, PMD и его JUnitTestsShouldIncludeAssert.
Я не фанат слепого поклонения каким-либо правилам и допускаю в одном тесте несколько действий и несколько проверок. Вместе с тем использование AssertJ сильно облегчает переход к парадигме один тест — один ассерт. Достигается это за счёт fluent API — одной из ключевых особенностей AssertJ.
@Test void shouldSatisfyContract() { assertThat(check) .hasType(Index.class) .hasDiagnostic(Diagnostic.INVALID_INDEXES) .hasHost(PgHostImpl.ofPrimary()); }
Больше примеров тут.
3. Откажитесь от традиционных ассертов
Ассерты в стиле JUnit были очень хороши... лет 20 назад. Сейчас они устарели и представляют собой пример не самого удачного дизайна. Сможете сходу вспомнить порядок следования expected и actual?
final String actual = doSomething(); // Так? Assertions.assertEquals(actual, "expected"); // Или так? Assertions.assertEquals("expected", actual);
А сможете научить всех своих инженеров, включая новичков, не путать их местами?
AssertJ by design лишён этой проблемы:
assertThat(actual).isEqualTo("expected");
4. Делайте ваши тесты более читаемыми, используя естественный язык
При использовании традиционных ассертов код ваших тестов выглядит искусственным и с трудом читается вслух. Сравните:
final Account a = makeAccount(); assertEquals("RussianAccount{id=1, currency=BaseCurrency(isoCode=RUB), number=30102810100000000001, active=true, balance=0, holder=Party{Revolut LLC, type=LEGAL_PERSON, tax identification number=7703408188, id=1}, chapter=BALANCE}", a.toString());
и
final Account a = makeAccount(); assertThat(a) .hasToString("RussianAccount{id=1, currency=BaseCurrency(isoCode=RUB), number=30102810100000000001, active=true, balance=0, holder=Party{Revolut LLC, type=LEGAL_PERSON, tax identification number=7703408188, id=1}, chapter=BALANCE}");
Вот ещё пример:
// JUnit assertEquals(1, cache.size()); // AssertJ assertThat(cache) .hasSize(1);
AssertJ предоставляет красивые и удобные методы для проверки типовых вещей: эквивалентности, хэш кода, размера коллекции и т.д.
assertThat(second) .isNotEqualTo(first) .doesNotHaveSameHashCodeAs(first);
assertThat(index.getIndexNames()) .hasSize(2) .containsExactly("index3", "index4") .isUnmodifiable();
Если вы работали с BigDecimal в тестах, то, вероятно, сталкивались с проблемой проверки значений из-за разного масштаба: обычно вместо equals приходится применять compareTo. AssertJ частично устраняет эту проблему за счёт метода isEqualByComparingTo:
@Test void moneyProblem() { final BigDecimal one = new BigDecimal("1.000"); // AssertJ assertThat(one).isEqualByComparingTo(BigDecimal.ONE); // pass // JUnit Assertions.assertEquals(0, one.compareTo(BigDecimal.ONE)); // pass Assertions.assertEquals(BigDecimal.ONE, one); // fail }
5. Полностью откажитесь от Hamcrest
Любой код живёт, развивается и рано или поздно умирает. Какие-то вещи сначала становятся популярными, а потом выходят из моды. Не цепляйтесь за устаревающие проекты. Hamcrest не радует нас новыми версиями с октября 2019. Просто замените его более современным решением:
// Было - Hamcrest assertThat(indexes.stream() .map(TableNameAware::getTableName) .collect(Collectors.toSet()), containsInAnyOrder("t", "demo.t", "test.t")); // Стало - AssertJ assertThat(indexes.stream() .map(TableNameAware::getTableName) .collect(Collectors.toSet())).containsExactlyInAnyOrder("t", "demo.t", "test.t");
6. Используйте возможности функционального подхода
AssertJ поддерживает функциональный подход и позволяет вам преобразовывать исходные данные. Предыдущий пример можно значительно улучшить, сократив количество кода и увеличив его читаемость:
assertThat(indexes) .flatExtracting(TableNameAware::getTableName) .containsExactlyInAnyOrder("t", "demo.t", "test.t");
А вот как можно работать с Optional<>:
assertThat(statisticsMaintenance.getLastStatsResetTimestamp()) .isPresent() .get() .satisfies(t -> assertThat(t).isAfter(testStartTime));
7. Расширяйте AssertJ для использования с вашими собственными типами
AssertJ предоставляет абстрактный базовый класс AbstractAssert<>, расширяя который, вы можете добавить поддержку своих собственных типов и методов для проверки. В некоторых случаях это позволяет заметно сократить количество тестового кода и повысить его выразительность. Пример:
assertThat(check) .hasType(Column.class) .hasDiagnostic(Diagnostic.COLUMNS_WITHOUT_DESCRIPTION) .hasHost(PgHostImpl.ofPrimary()) .executing() .isEmpty();
8. Получайте понятные логи, если тест упал
Выше я совсем не упомянул про поддержку в IntelliJ IDEA (code completion) и про то, что AssertJ даёт весьма подробные и читаемые логи, если тест падает:
[All diagnostics must be logged] Actual and expected should have same size but actual size is: 10 while expected size is: 12 Actual was: ["1999-12-31T23:59:59Z db_indexes_health invalid_indexes 0", ...
Можно добавить описание к последующему шагу теста:
@Test void completenessTest() { assertThat(logger.logAll(Exclusions.empty())) .as("All diagnostics must be logged") .hasSameSizeAs(Diagnostic.values()); }
А ещё можно переопределить сообщение об ошибке через overridingErrorMessage(), но в большинстве случаев это не требуется.
9. Защищайте себя от неправильного использования AssertJ
Если вы ещё не поняли, то я обожаю статический анализ кода. Эта волшебная штука, если её правильно приготовить, может делать за вас огромное количество работы!
Код AssertJ активно использует аннотацию @CheckReturnValue: методы assertThat(), as(), overridingErrorMessage() и некоторые другие размечены ею.
Если вы забудете после assertThat() вызвать какой-нибудь метод проверки, то SpotBugs упадёт с ошибкой RV_RETURN_VALUE_IGNORED.

Основная хитрость здесь в том, что нужно для SpotBugs явно выставить порог предупреждений в Low.
Для Maven-плагина:
<configuration> <includeTests>true</includeTests> <effort>Max</effort> <threshold>Low</threshold> </configuration>
Для Gradle-плагина:
spotbugs { effort = 'max' reportLevel = 'low' }
* * *
На этом всё. Больше примеров использования AssertJ вы сможете найти в моих проектах на GitHub.
Надеюсь, эта статья поможет сделать код ваших тестов чуточку лучше.
