Привет, Хабр!

В 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.

Надеюсь, эта статья поможет сделать код ваших тестов чуточку лучше.

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


  1. Raidar
    08.07.2022 06:19
    +2

    А почему не такое?

    assertThat("expected").isEqualTo(actual);


    1. aleksandy
      08.07.2022 07:16

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

      org.opentest4j.AssertionFailedError: 
      expected: "actual"
       but was: "expected"

      Что при более сложных проверках может вводить в заблуждение.


      1. dopusteam
        08.07.2022 07:20
        +2

        Сможете сходу вспомнить порядок следования expected и actual?

        AssertJ by design лишён этой проблемы

        Я думаю, это была отсылка к этой части


        1. Raidar
          08.07.2022 07:31
          +1

          Вы правы. В обоих случаях непонятно, где actual, а где expected.
          Тут среда рзработки обычно помогает подсказками, да и при частом использовании запоминаешь порядок.


      1. Raidar
        08.07.2022 07:32
        +1

        assertEquals также подсказывает в сообщении об ошибке.


    1. IvanVakhrushev Автор
      08.07.2022 09:12
      +3

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

      В assertThat() можно передать только один параметр - результат, который будем проверять. Это достаточно интуитивно. Я пока что не встречал разработчиков, у которых с этим бы возникли проблемы.

      assertEquals() из JUnit противоречит концепции "чистого кода" - два параметра одинакового типа, которые легко перепутать местами, и код скомпилируется. В большинстве случаев это не проблема, но иногда мешает: читать логи упавшего теста в CI-пайплайне (особенно когда локально тест проходит) или на code review, когда один разработчик поучает другого правильно использовать assertEquals.


    1. SimSonic
      10.07.2022 07:23

      Потому что цепочка будет проверять ожидаемое значение, а не актуальное.

      Представьте assertThat(123).isNotNull().isEqualTo(var);


  1. Zulkijind
    08.07.2022 15:52
    -1

    Нет, спасибо, нам достаточно TestNG, мы не голодные.


  1. Rhombus
    08.07.2022 22:37
    +4

    Не цепляйтесь за устаревающие проекты. Hamcrest не радует нас новыми версиями с октября 2019. Просто замените его более современным решением:

    Но почему это проблема, что он не обновлялся (если конечно там нет критических багов)? Это какой-то js подход получается, где обязательно нужно самый новый фреймворк, а все, что старше полугода, ужасное легаси?

    И забавно тоже, что пример с Hamcrest совпадает практически слово в слово с AssertJ.


  1. amedvedjev
    09.07.2022 21:46
    +2

    Я бы порекомендовал добавлять описание ошибки. Вот у нас я автомачу мобильные тесты один, а юзают их все манульщики. И писать 2 <> 3 не совсем ясно, что такое 2 и почему 3. Ошибки должен понимать человек кто видит продукт первый раз.

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


  1. dididididi
    10.07.2022 17:10
    -1

    Так много категоричных утверждений) А что вам это дало? Много народу уволилось, когда самодур начальник стал заставлять их делать всё только его любимой и единственно правильной библиотекой и никак иначе?


    1. IvanVakhrushev Автор
      10.07.2022 19:02

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

      Сопротивление новому - черта, свойственная любому человеку. AssertJ - капля в море. Внедрение checkstyle, например, вызывает гораздо большее сопротивление, но даже оно преодолимо без потерь.