1. Важность единого стиля assertions
Если вы сейчас думаете: «Ну какая разница, чем проверять, — главное, чтобы тест был зелёным», значит, вы ещё не поддерживали набор тестов месяцами. В реальном проекте проблема не в том, чтобы один тест написать. Проблема в том, чтобы через полгода открыть любой тест и быстро понять, что он доказывает, почему упал и как его чинить, не вызывая шамана по контекст-свитчам.
Представьте набор тестов ContentHub через пару недель обучения. Там будут unit-тесты бизнес-правил (например, переходов статусов статьи), тесты JSON-контракта DTO, потом тесты HTTP-границы контроллеров, дальше — слой данных и интеграционные сценарии. Если в каждом слое у вас «свой язык», мозг будет тратить время не на смысл, а на расшифровку синтаксиса: здесь Hamcrest, там AssertJ, тут JSON сравниваем как строку, а вон там вообще парсим руками. Это как читать одну книгу, где каждая глава написана разным алфавитом: технически можно, но удовольствие сомнительное.
Инструментов у нас уже несколько, и теперь главный риск не в отсутствии очередного метода, а в том, что набор тестов начинает говорить сразу на нескольких диалектах.
И тут появляется важное рабочее правило: мы выбираем один доминирующий стиль проверок, а остальные инструменты используем как «адаптеры» под конкретную форму результата. В нашем курсе этим доминирующим стилем будет AssertJ, потому что он хорошо масштабируется от простых значений до коллекций и объектов. И ещё он идеально сочетается с тем, что мы называем «тест как документация поведения».
2. Выбираем инструмент не по вкусу, а по форме результата
Очень хочется выбирать библиотеку по принципу «мне так красивее». Но в тестах это ловушка: красота быстро превращается в хаос, если у каждого разработчика вкус свой. Гораздо устойчивее выбирать инструмент так же, как мы выбираем тип теста: от задачи и формы результата, а не от личных предпочтений. Вопрос «какой инструмент проверок брать» начинается не с названия библиотеки, а с вопроса: что именно я получил на выходе теста?
Если тест вернул простое значение или объект Java, наша «родная среда» — AssertJ. Если на выходе JSON-строка (что будет часто на границе API), мы либо сравниваем JSON как контракт целиком (JSONassert), либо читаем конкретные поля (JsonPath) и уже эти значения проверяем AssertJ. Если инструмент или фреймворк вокруг вас ожидает matcher-объекты (а такое встречается в экосистеме Spring), Hamcrest становится неизбежным, но мы стараемся держать его на периферии, чтобы он не стал главным «диалектом» тестов.
Чтобы было совсем наглядно, вот маленькое «дерево решений» — не догма, а быстрый ориентир:
flowchart TD
A["Что вы проверяете?"] --> B["Java-значение / объект"]
A --> C["JSON как String"]
B --> BJ["AssertJ: assertThat(actual)..."]
C --> D["Нужна проверка целого JSON-контракта?"]
D -->|Да| E["JSONassert: структурное сравнение"]
D -->|Нет, нужен фрагмент| F["JsonPath: достать поле/элемент"]
F --> BJ
A --> G["Инструмент требует matcher?"]
G -->|Да| H["Hamcrest точечно (MatcherAssert.assertThat)"]
G -->|Нет| BJ
Важно заметить, что это дерево не говорит «Hamcrest плохой» или «JSONassert всегда лучше». Оно говорит другое: форма результата диктует инструмент, а не наоборот. И это сильно экономит время, когда вы пишете или читаете тест под давлением дедлайна (а дедлайн, как известно, всегда где-то рядом и любит подкрадываться бесшумно).
3. AssertJ как «язык по умолчанию» для тестов ContentHub
С Java-результатами решение уже почти не вызывает споров: строка, число, enum, коллекция, Optional, обычный доменный объект — по умолчанию берём AssertJ. Причина не в том, что он «самый модный», а в том, что он удерживает один и тот же ритм чтения теста: начинаем с actual и формулируем ожидания цепочкой, а не разбрасываем набор разрозненных asserts.
Здесь важен не повтор каталога методов, а сама привычка. Если результат живёт в мире Java-объектов, первым выбором почти всегда будет AssertJ. Исключения бывают, но их лучше воспринимать именно как исключения, а не как повод каждый раз переключать диалект.
JSON: JSONassert, JsonPath и AssertJ
На API-границе меняется не логика проверки, а форма результата: вместо объекта или коллекции часто приходит JSON-строка. И здесь уже бессмысленно спорить со строкой посимвольно. Если нужно зафиксировать payload как контракт целиком, берём JSONassert. Если нужно доказать отдельное правило внутри payload, берём JsonPath, достаём нужное поле и формулируем ожидание тем же AssertJ.
Хороший тест обычно выбирает один из этих режимов, а не оба сразу. Либо мы сравниваем форму JSON как единый контракт, либо адресно проверяем конкретные поля и связи. Так тест остаётся и точным, и не слишком хрупким.
Hamcrest: как не путать с AssertJ
Hamcrest полезно держать рядом не как второй основной язык, а как matcher-диалект для мест, где API просит именно matcher. В обычной Java-проверке он редко даёт выигрыш по читаемости относительно AssertJ, зато легко добавляет путаницу с двумя разными assertThat.
Поэтому рабочее правило простое: если мысль нормально выражается через AssertJ, так и делаем. Если внешний API ждёт matcher, подключаем Hamcrest точечно и лучше явно через MatcherAssert.assertThat(...). Когда эта граница понятна, общая карта по слоям тестов собирается почти без споров.
4. Матрица стилей: unit/JSON/MVC/data/live-server
Даже до появления MVC-, data- и live-server-сценариев полезно заранее договориться, каким языком будут описываться их проверки. Иначе один слой начнёт жить на fluent-цепочках, другой — на matcher-диалекте, а JSON-граница — на случайных строковых сравнениях. Это как договориться о языке общения в команде до того, как вы начали строить общий дом. Поменять потом можно, но будет больно, потому что часть стен уже из бетона, а часть — из разговоров в чате.
Ниже — практическая матрица выбора. Это не список «так надо всегда», а полезная карта: какие типы результатов чаще всего встречаются на каждом уровне тестов ContentHub и каким инструментом их удобнее проверять, сохраняя единый стиль.
| Уровень теста в ContentHub | Что обычно на выходе теста | Доминирующий инструмент | Дополнительный инструмент | Почему так проще читать и поддерживать |
|---|---|---|---|---|
| Unit (plain Java) | String, числа, enum, List, доменные объекты | AssertJ | иногда JsonPath/JSONassert (редко) | В unit-слое мы чаще всего проверяем Java-объекты и бизнес-правила, и AssertJ здесь самый прямой язык |
| JSON-контракт (DTO) | JSON как строка | JSONassert | JsonPath (для отдельных правил), AssertJ (для значений) | Контракт важно сравнивать структурно, а не посимвольно; точечные правила удобно вытаскивать JsonPath |
| MVC (mocked HTTP) | статус/заголовки + JSON body | AssertJ | Hamcrest там, где инструмент требует matcher; JSONassert/JsonPath для body | Даже если где-то встретится matcher-API, смысловые проверки тела ответа лучше держать в нашем базовом стиле |
| Data (репозитории/БД) | коллекции сущностей, поля, Optional | AssertJ | иногда сравнение строк/дат аккуратно | Слой данных — это много коллекций, объектов и вложенных данных; AssertJ отлично покрывает это без ручных циклов |
| Live-server (реальный HTTP) | статус/заголовки + JSON body как строка | AssertJ | JSONassert/JsonPath для body | Здесь вы почти всегда получаете JSON как строку, но проверяете смысл, поэтому JSON-инструменты работают как «адаптер» |
Общий вывод, который нам важнее таблицы: AssertJ — общий язык, а JSONassert/JsonPath/Hamcrest — специализированные инструменты, которые мы подключаем, когда форма результата этого требует.
Чтобы это не осталось теорией, возьмём типичный ответ в виде JSON-строки и проверим одно конкретное правило. Spring здесь не нужен: нам важен именно стиль, а не способ получения строки.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ResponseAssertionsStyleTest {
@Test
void response_should_contain_error_code() {
String body = """
{"status":404,"errorCode":"ARTICLE_NOT_FOUND"}
""";
// Извлекаем только то поле, которое важно для этого сценария.
String errorCode = JsonPath.read(body, "$.errorCode");
// Проверку формулируем в основном стиле suite — через AssertJ.
assertThat(errorCode).isEqualTo("ARTICLE_NOT_FOUND");
}
}
Здесь стиль однородный: итоговое ожидание в AssertJ, а JsonPath — просто способ вытащить фрагмент из JSON. И этот же паттерн будет одинаково уместен и в JSON-контракт тестах, и в MVC тестах, и в live-server тестах — меняется только источник строки body.
7. Минимальные вспомогательные методы для однородного набора тестов
Когда тестов становится много, появляется соблазн написать «свой фреймворк поверх фреймворков». Обычно всё начинается с невинной идеи «давайте сделаем helper для JSON», а заканчивается тем, что никто, кроме автора, не понимает, что реально проверяется. Поэтому здесь нужен очень взрослый компромисс: вспомогательные методы должны сокращать только шум, но не должны прятать смысл теста.
Для нашего курса разумный минимум — это пара функций для повторяющихся операций: прочитать значение по JsonPath и сделать lenient JSON-сравнение. Они не превращают тест в магию, потому что всё равно видно, какой path читаем и что сравниваем.
Вот пример такого «тонкого» помощника:
import com.jayway.jsonpath.JsonPath;
import org.skyscreamer.jsonassert.JSONAssert;
public final class JsonTestSupport {
private JsonTestSupport() {
// Утилитарный класс: экземпляры не нужны
}
public static <T> T read(String json, String path) {
// Единая точка входа для чтения значений из JSON по JsonPath,
// чтобы в тестах не плодились разные способы парсинга.
return JsonPath.read(json, path);
}
public static void assertLenientEquals(String expected, String actual) throws Exception {
// Lenient-сравнение: expected обязателен, но actual может содержать дополнительные поля.
JSONAssert.assertEquals(expected, actual, false);
}
}
И пример использования в тесте, где главный смысл всё ещё на поверхности:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class JsonTestSupportUsageTest {
@Test
void should_extract_slug_from_response() {
// Тело ответа в виде JSON: в тесте нам важно конкретное поле slug.
String body = """
{"slug":"java-basics","status":"DRAFT"}
""";
// Читаем поле по пути, не пряча смысл: прямо видно и JSON, и path.
String slug = JsonTestSupport.read(body, "$.slug");
// А ожидание формулируем в AssertJ-стиле.
assertThat(slug).isEqualTo("java-basics");
}
}
Почему это полезно именно для однородности набора тестов? Потому что вы стандартизируете мелкие операции, которые иначе каждый написал бы по-своему. Один человек использует JsonPath.read, другой — DocumentContext.parse, третий — руками через ObjectMapper (и заодно случайно меняет настройки). А так у вас есть один маленький и понятный путь, который не требует отдельного мини-курса по чтению.
8. Типичные ошибки при выборе стиля assertions
Ошибки в выборе assertion-стиля редко ломают приложение напрямую, но они отлично ломают вашу жизнь как автора тестов. Особенно неприятно то, что тесты могут оставаться зелёными, но становиться всё менее полезными: вы тратите время, а уверенности не прибавляется. Поэтому ошибки ниже — это не «смешные мелочи», а реальная причина, почему набор тестов в проектах превращается в тяжёлый груз.
Ошибка №1: выбирать библиотеку «по привычке», а не по форме результата.
Если тест вернул Java-объект, а вы упорно превращаете его в строку и сравниваете строку — вы усложняете себе задачу без выгоды. И наоборот: если тест вернул JSON, а вы сравниваете его как обычную строку, вы создаёте хрупкость. Правильный рефлекс звучит так: «что у меня на выходе?» — и только потом «чем проверять?».
Ошибка №2: смешивать AssertJ и Hamcrest в одном тесте так, что непонятно, где вы сейчас.
Особенно коварно то, что оба мира любят слово assertThat. Потом вы открываете тест, видите assertThat(...), а IDE показывает «не те методы», и начинается мини-детектив. Спасает дисциплина: AssertJ — через статический импорт, Hamcrest — через MatcherAssert.assertThat(...) явно. Тогда даже глазами видно, какой стиль применяется.
Ошибка №3: делать и JSONassert, и десяток JsonPath-проверок по одним и тем же полям в одном тесте.
Такой тест выглядит «очень надёжным», но на деле он просто многословный. При изменении контракта он падает в нескольких местах, а вы тратите время на починку дубликатов. Выберите ведущую стратегию: либо контракт целиком (JSONassert), либо отдельные правила (JsonPath + AssertJ).
Ошибка №4: использовать lenient JSON-сравнение там, где вам нужна строгая фиксация контракта.
Lenient хорош, когда вы осознанно сравниваете только часть. Но если тест должен защищать контракт DTO, lenient превращает тест в «я вроде что-то сравнил, но не очень понимаю, что именно доказал». В таких случаях строгость — ваш друг, потому что она ловит регрессии формы ответа, а не только смысл отдельных полей.
Ошибка №5: писать вспомогательные методы, которые скрывают смысл теста.
Помощники должны убирать повторяющийся шум, а не превращать тест в assertArticleIsOk(result), где внутри «немного магии, немного надежды и один случайный sleep» (ладно, sleep у нас ещё не было, но шутка пусть останется предупреждением). Если читатель не может по тесту понять, что проверяется, без похода в три утилитарных класса, — helper перешёл грань.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ