Подсказки для модульного тестирования с AssertJ

Unit testing стало стандартной частью разработки. Существует множество инструментов, которые можно использовать для этого разными способами. В данной статье демонстрируется пара подсказок или, можно сказать, лучших практик, которые хорошо работают для меня.

В этой статье вы узнаете

  • Как писать чистые и читаемые юнит-тесты с использованием JUnit и фреймворков ассерций
  • Как избегать ложных положительных тестов в некоторых случаях
  • Что следует избегать при написании юнит-тестов

Не злоупотребляйте проверками NPE

Мы все стараемся избегать NullPointerException в основном коде, поскольку это может привести к неприятным последствиям. Я считаю, что наша главная задача не в том, чтобы избегать NPE в тестах. Наша цель — проверить поведение проверяемого компонента чистым, читаемым и надежным способом.

Плохая практика

Множество раз в прошлом я использовал ассерт isNotNull даже тогда, когда он не был необходим, как в примере ниже:

Java

 

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

Этот тест выдает ошибки следующего вида:

Plain Text

 

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

Хорошая практика

Хотя дополнительный ассерт isNotNull и не наносит реального вреда, его следует избегать по следующим причинам:

  • Он не добавляет никакой дополнительной ценности. Это просто больше кода для чтения и поддержки.
  • Тест все равно терпит неудачу, когда service равно null, и мы видим реальную причину сбоя. Тест все еще выполняет свою задачу.
  • Сгенерированное сообщение об ошибке становится еще лучше с утверждением AssertJ.

Смотрите измененное утверждение теста ниже.

Java

 

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

Измененный тест выдает ошибку следующего вида:

Java

 

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)

Примечание: Пример можно найти в SimpleSpringTest.

Проверяйте Значения, а Не Результат

Иногда мы пишем правильный тест, но не в лучшей форме. Это означает, что тест работает именно так, как задумано, и проверяет наш компонент, но неудача не предоставляет достаточно информации. Поэтому наша цель — проверять значение, а не результат сравнения.

Плохая Практика

Рассмотрим пару таких плохих тестов:

Java

 

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

Ниже показаны некоторые ошибки из вышеупомянутых тестов.

Plain Text

 

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

Хорошая Практика

Решение довольно простое с помощью AssertJ и его флюидного API. Все упомянутые выше случаи можно легко переписать как:

Java

 

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

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

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

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

Те же самые ошибки, о которых упоминалось ранее, теперь предоставляют больше ценности.

Plain Text

 

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

Примечание: Пример можно найти в SimpleParamTests.

Группировка связанных утверждений

Последовательное соединение утверждений и соответствующая вложенность кода значительно улучшают ясность и читаемость тестов.

Плохая практика

При написании теста мы можем получить правильный, но менее читаемый тест. Представьте тест, в котором мы хотим найти страны и выполнить следующие проверки:

  1. Подсчет найденных стран.
  2. Проверка первого записи с несколькими значениями.

Такие тесты могут выглядеть, как в этом примере:

Java

 

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

Хорошая практика

Хотя предыдущий тест является правильным, мы должны значительно улучшить читаемость, объединив связанные утверждения вместе (строки 9-11). Цель здесь – один раз утвердить result и написать столько последовательных утверждений, сколько необходимо. См. модифицированную версию ниже.

Java

 

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

Примечание: Пример можно найти в CountryRepositoryOtherTests.

Предотвращение ложно-положительных успешных тестов

Когда используется любой метод утверждения с аргументом ThrowingConsumer, то в этом аргументе также должно содержаться assertThat. В противном случае, тест будет проходить всегда — даже когда сравнение неверно, что означает неправильный тест. Тест завершается с ошибкой только если утверждение вызывает исключение RuntimeException или AssertionError. Я думаю, это понятно, но легко забыть об этом и написать неправильный тест. Такое случается со мной время от времени.

Плохая практика

Представим, что у нас есть несколько кодов стран и мы хотим проверить, что каждый код удовлетворяет определенному условию. В нашем примере-заполнителе мы хотим утверждать, что каждый код страны содержит символ “a”. Как видите, это бессмыслица: у нас коды в верхнем регистре, но мы не применяем нечувствительность к регистру в утверждении.

Java

 

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

Удивительно, но наш тест успешно прошел.

Хорошая практика

Как упоминалось в начале этого раздела, наш тест легко можно исправить, добавив дополнительное assertThat в потребитель (строка 7). Правильный тест должен выглядеть следующим образом:

Java

 

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

Теперь тест завершается с ошибкой, как и ожидалось, с правильным сообщением об ошибке.

Plain Text

 

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)

Цепочка утверждений

Последний совет не совсем является практикой, а скорее рекомендацией. API плавного взаимодействия AssertJ следует использовать для создания более читаемых тестов.

Нецепочечные утверждения

Рассмотрим тест listLogs, цель которого — проверить логирование компонента. Здесь задача состоит в том, чтобы проверить:

  • Утвержденное количество собранных логов
  • Утверждение существования сообщений логов DEBUG и INFO
Java

 

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

Цепочка утверждений

С упомянутым флюидным API и цепочкой мы можем изменить тест следующим образом:

Java

 

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

Примечание: пример можно найти в AppleTest.

Итоги и исходный код

Фреймворк AssertJ предоставляет множество возможностей благодаря своему флюидному API. В данной статье было представлено несколько советов и подсказок для создания более понятных и надежных тестов. Следует помнить, что большинство из этих рекомендаций субъективны. Они зависят от личных предпочтений и стиля кодирования.

Использованный исходный код можно найти в моих репозиториях:

Source:
https://dzone.com/articles/hints-for-unit-testing-with-assertj