Unit testing стало стандартной частью разработки. Существует множество инструментов, которые можно использовать для этого разными способами. В данной статье демонстрируется пара подсказок или, можно сказать, лучших практик, которые хорошо работают для меня.
В этой статье вы узнаете
- Как писать чистые и читаемые юнит-тесты с использованием JUnit и фреймворков ассерций
- Как избегать ложных положительных тестов в некоторых случаях
- Что следует избегать при написании юнит-тестов
Не злоупотребляйте проверками NPE
Мы все стараемся избегать NullPointerException
в основном коде, поскольку это может привести к неприятным последствиям. Я считаю, что наша главная задача не в том, чтобы избегать NPE в тестах. Наша цель — проверить поведение проверяемого компонента чистым, читаемым и надежным способом.
Плохая практика
Множество раз в прошлом я использовал ассерт isNotNull
даже тогда, когда он не был необходим, как в примере ниже:
@Test
public void getMessage() {
assertThat(service).isNotNull();
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
Этот тест выдает ошибки следующего вида:
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.
Смотрите измененное утверждение теста ниже.
@Test
public void getMessage() {
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
Измененный тест выдает ошибку следующего вида:
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.
Проверяйте Значения, а Не Результат
Иногда мы пишем правильный тест, но не в лучшей форме. Это означает, что тест работает именно так, как задумано, и проверяет наш компонент, но неудача не предоставляет достаточно информации. Поэтому наша цель — проверять значение, а не результат сравнения.
Плохая Практика
Рассмотрим пару таких плохих тестов:
// #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();
Ниже показаны некоторые ошибки из вышеупомянутых тестов.
#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. Все упомянутые выше случаи можно легко переписать как:
// #1
assertThat(argument).contains("o");
// #2
assertThat(result).isInstanceOf(String.class);
// #3
assertThat("").isBlank();
// #4
assertThat(testMethod).isPresent();
Те же самые ошибки, о которых упоминалось ранее, теперь предоставляют больше ценности.
#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.
Группировка связанных утверждений
Последовательное соединение утверждений и соответствующая вложенность кода значительно улучшают ясность и читаемость тестов.
Плохая практика
При написании теста мы можем получить правильный, но менее читаемый тест. Представьте тест, в котором мы хотим найти страны и выполнить следующие проверки:
- Подсчет найденных стран.
- Проверка первого записи с несколькими значениями.
Такие тесты могут выглядеть, как в этом примере:
@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
и написать столько последовательных утверждений, сколько необходимо. См. модифицированную версию ниже.
@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”. Как видите, это бессмыслица: у нас коды в верхнем регистре, но мы не применяем нечувствительность к регистру в утверждении.
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> countryCode.contains("a"));
}
Удивительно, но наш тест успешно прошел.
Хорошая практика
Как упоминалось в начале этого раздела, наш тест легко можно исправить, добавив дополнительное assertThat
в потребитель (строка 7). Правильный тест должен выглядеть следующим образом:
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> assertThat( countryCode ).containsIgnoringCase("a"));
}
Теперь тест завершается с ошибкой, как и ожидалось, с правильным сообщением об ошибке.
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
@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 и цепочкой мы можем изменить тест следующим образом:
@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