Команда 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 и всего, что с ним связано.
Комментарии (4)
 - aleksandy27.05.2025 23:44- КГ/АМ. - иметь только один оператор assert в тесте - А что есть "оператор assert"? По-моему, очевидно, что под оператором понимается законченная проверка какого-либо объекта. Соответственно, если нужно проверить состояние объекта из 100500 полей, то именно это и должно быть выполнено в тесте. Не нравится простыни из - assertEquals()/- assertThat()- вынеси в отдельный метод- assertSomeObjects()и в используй его в тесте.
 
           
 
nin-jin
Более того, кроме проверки пост условий, часто надо проверять и предусловия. Чтобы не казалось так, что действие ничего не делает, а объект по дефолту уже в нужном состоянии.
Occama
Тут более тонкий момент. Вообще, тест должен стартовать из контролируемых условий. Мне сложно представить, в каких случаях юнит-тест будет проводиться с предусловиями, не контролируемыми самим набором сетапов и тестов. Да, это может зависеть от условных переменных окружения, контрактов внешних систем или чего-то подобного, но тогда это уже превращает его в интеграционный тест, а это про другое, всё же оригинальное правило сингл ассерта про юнит-тесты написано. Да и, опять же, если у нас фейлятся предусловия, то мы оказываемся в странной серой зоне, где тест и не успешен, и не провален.
nin-jin