Команда Spring АйО перевела статью эксперта Михаила Поливахи о том, почему правило о единственном assert'е на тест иногда можно и нужно нарушать.
Я искренне верю, что большинство людей не совершают зла намеренно (хотя некоторые — да, совершают) Многие проблемы современного мира возникают из‑за недопонимания и различных точек зрения на те или иные вопросы, усугублённых человеческими эмоциями, культурными различиями и другими факторами.
В частности, я считаю, что афоризм:
«Дорога в ад вымощена благими намерениями»
весьма точен. Одно из его возможных толкований заключается в том, что, навязывая определённые нормы поведения или политики, которые, как нам кажется, будут полезны для большинства, мы зачастую можем непреднамеренно усугубить общую ситуацию.
Например, так называемое «правило единственного assert'а», изложенное в книге Clean Code, я нередко нахожу запутанным и даже непрактичным во многих случаях, связанных с разработкой программного обеспечения в целом.
Это правило иногда трактуется так, что тест должен падать по единственной причине. Однако, что именно подразумевается под этой «единственной причиной», остаётся довольно расплывчатым, поэтому спорить с этим утверждением бывает непросто.
Тем не менее, многие разработчики понимают это правило буквально — как требование иметь только один оператор assert в тесте. И мой совет вам — не следовать этому правилу слишком строго и отходить от него, если того требует ситуация.
Почему?
Потому что программное обеспечение — это крайне разнообразная и сложная сфера. Давайте представим, что мы пишем фабричный класс, примерно вот такой:
public class ConfigurationFactory {
public static Configuration createInstance() {
String javaVersion = System.getProperty("java.version");
String user = System.getenv("USER");
int cpusAvailable = Runtime.getRuntime().availableProcessors();
return new Configuration(javaVersion, user, cpusAvailable);
}
static class Configuration {
String javaVersion;
String user;
int cpusAvailable;
// constructor, getters etc.
}
}
Приведённый выше пример — это Java-код, реализующий фабрику, которая создаёт объект, предварительно настраивая его. Этот шаблон, по крайней мере с точки зрения семантики, довольно распространён в разработке в целом.
Теперь допустим, что мы хотим протестировать метод createInstance()
этой фабрики. И вот возникает вопрос — какое именно поведение мы хотим протестировать?
Очевидно, мы хотим убедиться, что создаваемый объект инициализируется в состоянии, которое считается корректным в рамках текущего тестового окружения. Звучит логично.
Но тогда возникает следующий вопрос — как именно мы будем проверять это состояние?
Следуя правилу
Если следовать «правилу единственного assert’а», то мне пришлось бы ограничиться единственной проверкой внутри теста. И тут становится очевидным: если я не хочу жертвовать качеством тестирования, мне нужно вручную создать экземпляр Configuration
в рамках теста. А затем сравнить, совпадает ли внутреннее состояние этой вручную сконструированной Configuration
с той, которую возвращает фабрика.
Проблема №1
В приведённом выше примере объект намеренно является достаточно компактным, т.к. служит для целей примера. Но если объект большой, то его ручное создание с, например, 30 полями будет абсолютно:
Скучным (а это куда более серьёзная проблема, чем может показаться на первый взгляд)
Подверженным ошибкам
Трудным в сопровождении (представьте, как «весело» будет добавлять 31-е поле).
Суть этой сложности в том, что объект изначально не задумывался для ручного создания. Просто не был. Именно поэтому мы и используем фабрику. Не говоря уже о том, что конструктор мог быть не public
.
Есть и другая проблема: мне может потребоваться проверить только часть полей на точное соответствие. Например, если фабрика генерирует случайный UUID
, я просто не смогу вручную создать тот же самый UUID
в тесте — мне нужно лишь убедиться, что UUID
:
установлен фабрикой,
и соответствует спецификации.
Проблема №2
Хорошо, допустим, что объект достаточно лёгкий, и мы можем позволить себе создать его вручную и сравнивать без особых затрат. Но тут возникает другая проблема.
Видите ли, я не знаю, на каком языке программирования вы пишете, но в Java по умолчанию при сравнении двух ссылок на объекты проверяется лишь то, указывают ли они на один и тот же участок памяти в heap-е Java процесса в рамках ОС.
А в нашем случае очевидно, что создание отдельного объекта вручную нам не поможет, поскольку это будет другой объект, пусть даже с теми же значениями в полях.
Что можно сделать? Мы можем переопределить методы equals()
и hashCode()
в Java, чтобы сравнение происходило по содержимому полей, а не по ссылкам:
static class Configuration {
String javaVersion;
String user;
int cpusAvailable;
// constructor, getters etc.
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Configuration that = (Configuration) o;
return cpusAvailable == that.cpusAvailable && Objects.equals(javaVersion, that.javaVersion) && Objects.equals(user,
that.user);
}
@Override
public int hashCode() {
return Objects.hash(javaVersion, user, cpusAvailable);
}
}
Так что, хорошо ли это?
Нет. Это ужасно.
Почему всё так плохо? Потому что наш тестовый код начинает диктовать, как должен выглядеть продуктивный код. А этого никогда не должно происходить.
В более общем смысле, проблема в том, что такой подход заставляет абстракции в коде развиваться неестественным образом, и в конечном итоге это приводит к появлению странного, неудобного и противоречивого API для пользователей. Поэтому такой сценарий следует избегать любой ценой.
Решение
Просто пишите несколько assert’ов, если считаете это уместным. Не бойтесь.
Вас за это не посадят, честное слово.
В этом нет ничего сложного:
@Test
void testCreateInstance() {
Configuration instance = ConfigurationFactory.createInstance();
assertSoftly(softAssertions -> {
softAssertions.assertThat(instance.getUser()).isEqualTo("my_user");
softAssertions.assertThat(instance.getJavaVersion()).isEqualTo("17.0.4.1");
});
}
В результате вам не только не нужно создавать объект вручную, но и можно проверить только те поля, которые действительно вас интересуют, и на тех условиях, которые имеют смысл (вспомните пример с UUID
). Вы не вынуждаете ваш класс Configuration
переопределять equals()
/hashCode()
или подстраиваться под какие-либо особенности, нужные только для тестов.
Так что, как это часто бывает в жизни, просто руководствуйтесь здравым смыслом, принимая решения.
Хорошего дня!

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.