Tips for unit testing with AssertJ

Unit testing has become a standard part of development. Many tools can be used for this in many different ways. This article shows some tips or let’s say best practices that work well for me.

In this article you will learn

Do not overuse NPE checks

We all tend to avoid NullPointerException as much as possible in the main code because it can lead to ugly consequences. I believe our main concern is not to avoid NPEs in tests. Our goal is to verify the behavior of the tested component in a clean, readable and reliable way.

Bad practice

I have used many times in the past isNotNull assertion even when it wasn’t needed, as in the example below:

@Test
public void getMessage() 
	assertThat(service).isNotNull();
	assertThat(service.getMessage()).isEqualTo("Hello world!");

This test produces the following errors:

java.lang.AssertionError: 
Expecting actual not to be null
	at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)

Good exercise

Although additional isNotNull the claim is not really harmful, it should be avoided for the following reasons:

  • It does not add any additional value. It’s just more code to read and maintain.
  • The test fails when anyway service is null and we see the real root cause of failure. The test still serves its purpose.
  • The error message produced is even better with AssertJ.

See the modified test assertion below.

@Test
public void getMessage() 
	assertThat(service.getMessage()).isEqualTo("Hello world!");

The modified test produces an error like this:

java.lang.NullPointerException: Cannot invoke "com.github.aha.poc.junit.spring.HelloService.getMessage()" because "this.service" is null
	at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)

Note: an example can be found in SimpleSpringTest.

Validate the values, not the result

Every now and then we write the correct test, but in a “bad” way. This means that the test works exactly as intended and checks our component, but the failure does not provide enough information. Therefore, our goal is to confirm the value, not the result of the comparison.

Bad practice

Let’s look at a few such bad tests:

// #1
assertThat(argument.contains("o")).isTrue();

// #2
var result = "Welcome to JDK 10";
assertThat(result instanceof String).isTrue();

// #3
assertThat("".isBlank()).isTrue();

// #4
Optional<Method> testMethod = testInfo.getTestMethod();
assertThat(testMethod.isPresent()).isTrue();

Some errors from the above tests are shown below.

#1
Expecting value to be true but was false
	at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
	at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
  
#3
Expecting value to be true but was false
	at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
	at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)

Good exercise

The solution is very simple with AssertJ and its fluent API. All the above cases can be easily rewritten as:

// #1
assertThat(argument).contains("o");

// #2
assertThat(result).isInstanceOf(String.class);

// #3
assertThat("").isBlank();

// #4
assertThat(testMethod).isPresent();

The same mistakes as mentioned before now provide more value.

#1
Expecting actual:
  "Hello"
to contain:
  "f" 
	at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
    
#3
Expecting blank but was: "a"
	at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)

Note: an example can be found in SimpleParamTests.

Group claims together

Assertion chaining and associated code indentation help a lot with test clarity and readability.

Bad practice

As we write the test, we may end up with an accurate but less readable test. Let’s imagine a test where we want to find countries and perform these checks:

  1. Count the countries found.
  2. Confirm the first entry with several values.

Such tests may look like this example:

@Test
void listCountries() 
	List<Country> result = ...;

	assertThat(result).hasSize(5);
	var country = result.get(0);
	assertThat(country.getName()).isEqualTo("Spain");
	assertThat(country.getCities().stream().map(City::getName)).contains("Barcelona");

Good exercise

Although the previous test is correct, we should greatly improve readability by grouping related statements (lines 9-11). The goal here is to assert result write many chained statements once and as needed. Check out the modified version below.

@Test
void listCountries() 
	List<Country> result = ...;

	assertThat(result)
		.hasSize(5)
		.singleElement()
		.satisfies(c -> 
			assertThat(c.getName()).isEqualTo("Spain");
			assertThat(c.getCities().stream().map(City::getName)).contains("Barcelona");
		);

Note: an example can be found in CountryRepositoryOtherTests.

Prevent a false positive from a successful test

When any method of asserting s ThrowingConsumer argument is used, then the argument must contain assertThat and with consumers. Otherwise, the test would pass all the time – even when the comparison fails, meaning the test is wrong. The test fails only when the statement throws out a RuntimeException or AssertionError exception. I guess it’s clear, but it’s easy to forget about it and write the wrong test. It happens to me from time to time.

Bad practice

Let’s imagine that we have several country codes and we want to verify that each code meets some condition. In our mock case, we want to assert that each country code contains the character “a”. As you can see, it’s nonsense: we have the codes in uppercase, but we don’t apply case-insensitivity to the assertion.

@Test
void assertValues() throws Exception 
	var countryCodes = List.of("CZ", "AT", "CA");
	
	assertThat( countryCodes )
		.hasSize(3)
		.allSatisfy(countryCode -> countryCode.contains("a"));

Surprisingly, our test passed successfully.

False positive correct test result

Good exercise

As mentioned at the beginning of this section, our test can be easily corrected by additional assertThat in the consumer (line 7). A correct test should be like this:

@Test
void assertValues() throws Exception 
	var countryCodes = List.of("CZ", "AT", "CA");
	
	assertThat( countryCodes )
		.hasSize(3)
		.allSatisfy(countryCode -> assertThat( countryCode ).containsIgnoringCase("a"));

Now the test fails as expected with the correct error message.

java.lang.AssertionError: 
Expecting all elements of:
  ["CZ", "AT", "CA"]
to satisfy given requirements, but these elements did not:

"CZ"
error: 
Expecting actual:
  "CZ"
to contain:
  "a"
 (ignoring case)
	at com.github.aha.sat.core.clr.AppleTest.assertValues(AppleTest.java:45)

Chained claims

The last tip is actually not a practice, but rather a recommendation. The AssertJ fluent API should be used to create more readable tests.

Assertions without chaining

Let’s consider listLogs test, the purpose of which is to test the writing of the component. The goal here is to check:

  • Confirmed number of logs collected
  • Establish existence DEBUG and INFO log message
@Test
void listLogs() throws Exception 
	ListAppender<ILoggingEvent> logAppender = ...;
	
	assertThat( logAppender.list ).hasSize(2);
	assertThat( logAppender.list ).anySatisfy(logEntry -> 
			assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
			assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
		);
	assertThat( logAppender.list ).anySatisfy(logEntry -> 
			assertThat( logEntry.getLevel() ).isEqualTo(INFO);
			assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
		);

Assertion chaining

With the aforementioned fluent API and chaining, we can change the test as follows:

@Test
void listLogs() throws Exception 
	ListAppender<ILoggingEvent> logAppender = ...;
	
	assertThat( logAppender.list )
		.hasSize(2)
		.anySatisfy(logEntry -> 
			assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
			assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
		)
		.anySatisfy(logEntry -> 
			assertThat( logEntry.getLevel() ).isEqualTo(INFO);
			assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
		);

Note: an example can be found in AppleTest.

Summary and source code

The AssertJ framework provides a lot of help with its fluid API. This article presents some tips and advice to produce clearer and more reliable tests. Keep in mind that most of these recommendations are subjective. It depends on personal preference and code style.

The source code used can be found in my repositories:

Source link

Leave a Reply

Your email address will not be published. Required fields are marked *