AssertJ as a way to dramatically improve your test code

Over the past couple of years, despite being in a managerial position, I’ve written over five hundred tests, and my approach to testing has changed a lot. In this article I will try to explain why AssertJ is the best solution for checks in tests that exists today (year 2022 from A.X.). Of course, everything below is my subjective opinion.

1. Switch to JUnit 5 if not already

Yes, the captain’s advice, but really important. Older versions should sink into oblivion, including JUnit 4. In all projects where I participate, explicitly through checkstyle I prohibit the use of classes from JUnit 4 (example here).

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

I do this because it is often impossible to completely remove JUnit 4 from the classpath, for example, because of Testcontainers (see. issue).

2. Structure your tests

I am a supporter of the AAA approach: Arrange-Act-Assert. You can also use Given-When-Then – this does not fundamentally change the essence. Require developers to have an assertion in every test! For control, you can (and should!) use a static analyzer, for example, PMD and its JUnitTestsShouldIncludeAssert.

I am not a fan of blind worship of any rules and allow several actions and several checks in one test. However, the use AssertJ greatly facilitates the transition to the paradigm of one test – one assert. This is achieved through fluent API – one of the key features AssertJ.

@Test
void shouldSatisfyContract() {
    assertThat(check)
        .hasType(Index.class)
        .hasDiagnostic(Diagnostic.INVALID_INDEXES)
        .hasHost(PgHostImpl.ofPrimary());
}

More examples here.

3. Ditch traditional assertions

JUnit-style assertions were very good… 20 years ago. Now they are outdated and represent an example of not the most successful design. Can you immediately remember the order of expected and actual?

final String actual = doSomething();
// Так?
Assertions.assertEquals(actual, "expected");
// Или так?
Assertions.assertEquals("expected", actual);

And can you teach all your engineers, including beginners, not to confuse their places?

AssertJ by design does not have this problem:

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

4. Make your tests more readable using natural language

With traditional assertions, your test code looks artificial and hard to read aloud. Compare:

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());

and

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}");

Here’s another example:

// JUnit
assertEquals(1, cache.size());

// AssertJ
assertThat(cache)
    .hasSize(1);

AssertJ provides beautiful and convenient methods for checking typical things: equivalence, code hash, collection size, etc.

assertThat(second)
    .isNotEqualTo(first)
    .doesNotHaveSameHashCodeAs(first);
assertThat(index.getIndexNames())
    .hasSize(2)
    .containsExactly("index3", "index4")
    .isUnmodifiable();

If you have worked with BigDecimal in tests, you have probably encountered the problem of checking values ​​due to different scales: usually instead of equals have to apply compareTo. AssertJ partially eliminates this problem due to the method 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. Get rid of Hamcrest completely

Any code lives, develops and sooner or later dies. Some things first become popular, and then go out of fashion. Do not cling to obsolete projects. Hamcrest does not please us with new versions from October 2019. Just replace it with a more modern solution:

// Было - 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. Take advantage of the functional approach

AssertJ supports a functional approach and allows you to transform the original data. The previous example can be greatly improved by reducing the amount of code and making it more readable:

assertThat(indexes)
    .flatExtracting(TableNameAware::getTableName)
    .containsExactlyInAnyOrder("t", "demo.t", "test.t");

Here’s how to work with Optional<>:

assertThat(statisticsMaintenance.getLastStatsResetTimestamp())
    .isPresent()
    .get()
    .satisfies(t -> assertThat
    .hasType(Column.class)
    .hasDiagnostic(Diagnostic.COLUMNS_WITHOUT_DESCRIPTION)
    .hasHost(PgHostImpl.ofPrimary())
    .executing()
    .isEmpty();

8. Get clear logs if a test fails

Above, I did not mention at all about support in IntelliJ IDEA (code completion) and about the fact that AssertJ gives very detailed and readable logs if the test fails:

[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",
...

You can add a description to the subsequent test step:

@Test
void completenessTest() {
    assertThat(logger.logAll(Exclusions.empty()))
        .as("All diagnostics must be logged")
        .hasSameSizeAs(Diagnostic.values());
}

You can also override the error message with overridingErrorMessage()but in most cases this is not required.

9. Protect yourself from the misuse of AssertJ

If you haven’t figured it out yet, I love static code analysis. This magical thing, if cooked right, can do a lot of work for you!

The code AssertJ actively uses abstract @CheckReturnValue: methods assertThat(), as(), overridingErrorMessage() and some others are marked by it.

If you forget after assertThat() call some validation method, then SpotBugs will fail with an error RV_RETURN_VALUE_IGNORED.

Error from static analyzer
Error from static analyzer

The main trick here is that you need to SpotBugs explicitly set the warning threshold in Low.

For Maven plugin:

<configuration>
    <includeTests>true</includeTests>
    <effort>Max</effort>
    <threshold>Low</threshold>
</configuration>

For Gradle plugin:

spotbugs {
    effort="max"
    reportLevel="low"
}

* * *

That’s all. More use cases AssertJ you can find in my projects on GitHub.

I hope this article helps make your test code a little better.

Similar Posts

Leave a Reply