1. Боль базовых проверок JUnit 6
Если вы только начали писать тесты, то assertEquals, assertTrue и assertFalse кажутся вполне достаточными. Но как только вы проверяете не «1 + 1 = 2», а поведение реального бизнес-кода — пусть даже совсем небольшого, — количество проверок быстро растёт. И внезапно тест начинает выглядеть не как сценарий, а как бухгалтерский отчёт.
Давайте представим очень простой результат: мы получили slug статьи (в ContentHub slug нужен для URL и должен быть читаемым). Вроде бы обычная строка. Но даже здесь обычно хочется проверить больше одного свойства: что slug равен ожидаемому значению, начинается с нужного префикса, не содержит пробелов и не пустой. На «голых» JUnit-assert’ах это быстро превращается в россыпь разрозненных вызовов, в которой глазу тяжело зацепиться за смысл.
Вот пример в стиле «чистый JUnit 6». Он не плохой — просто… шумный:
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
class SlugFormatTest {
@Test
void slug_is_readable_for_url() {
// Фактическое значение, которое мы проверяем (actual)
String slug = "java-basics";
// Проверяем ожидаемое значение целиком
Assertions.assertEquals("java-basics", slug);
// Проверяем отдельные свойства, но они выглядят как независимые утверждения
Assertions.assertTrue(slug.startsWith("java"));
Assertions.assertFalse(slug.contains(" "));
}
}
Проблема не в том, что код неправильный. Проблема в том, что он читается как «три независимых утверждения», а не как «одно ожидаемое поведение». А когда такой тест падает, сообщение об ошибке часто получается слишком общим: вы видите, что assertTrue упал, но по умолчанию не всегда понятен контекст — что именно вы проверяли и почему это важно.
И вот тут появляется AssertJ: он позволяет писать проверки как связное утверждение о результате, а не как набор отдельных «пингов» в сторону Assertions.*.
2. AssertJ как базовый стиль курса
AssertJ — это библиотека assertions с fluent-API, то есть с «цепочками» вызовов. Благодаря этому проверки читаются почти как предложение на человеческом языке. «Почти» — потому что это всё же Java, а не русский. Но прогресс заметный: тест становится ближе к описанию поведения, а не к набору технических проверок.
Важный практический момент: в типичном Spring Boot проекте (и в нашем учебном ContentHub тоже) AssertJ обычно уже есть в тестовых зависимостях, потому что его приносит spring-boot-starter-test. То есть вам не нужно «собирать тестовый стек вручную», как будто вы крафтите меч в RPG. Достаточно просто понимать, как им пользоваться.
Почему именно AssertJ станет «языком по умолчанию» в курсе:
Во-первых, fluent-цепочка помогает держать фокус на смысле: вы начинаете с фактического значения и дальше навешиваете ожидания. Во-вторых, сообщения об ошибках у AssertJ обычно информативнее: библиотека старается показать фактическое значение, ожидаемое и контекст. В-третьих, этот стиль хорошо масштабируется: те же принципы работают и для строк, и для коллекций, и для вложенных объектов, и для JSON. Не приходится каждый раз переключать «манеру письма».
Сразу оговоримся: JUnit-assert’ы никуда не исчезают. Но в этом курсе мы фиксируем простое правило: если вы сомневаетесь, чем проверять, — начинайте с AssertJ.
3. Точка входа: assertThat(actual)
Когда люди впервые видят AssertJ, они часто думают: «Окей, новая библиотека — значит, новый набор магических методов». На самом деле ключевая идея очень простая: вы всегда начинаете с assertThat(...), куда передаёте фактическое значение, а дальше описываете, каким оно должно быть.
И тут важно запомнить одно «железное» правило, которое спасает от глупых ошибок: в assertThat(...) вы кладёте actual, то есть то, что получилось в коде. Не expected. Не «как должно быть». А то, что реально получилось после Act-части теста.
Пример с минимальной настройкой (показываю сразу с импортами, чтобы не казалось, будто assertThat — это какая-то телепатия):
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AssertJIntroTest {
@Test
void assert_that_starts_from_actual_value() {
// Arrange: подготовили пример результата
String slug = "java-basics";
// Assert: начинаем с actual (slug), а дальше описываем ожидание
assertThat(slug).isEqualTo("java-basics");
}
}
Читается это так: «Утверждаю, что slug равен "java-basics"». И дальше на этом можно строить цепочку.
Если вы когда-нибудь ловили себя на том, что пишете assertEquals(expected, actual) и постоянно путаете порядок аргументов, то AssertJ как будто говорит: «Давай я уберу здесь шанс ошибиться». В AssertJ порядок естественнее: сначала фактическое значение, потом ожидания.
4. Fluent-цепочки для связанных проверок
Самое приятное в AssertJ — возможность «склеить» несколько проверок в одну цепочку, если они относятся к одному смысловому объекту. Например, slug должен равняться ожидаемому значению, начинаться с нужного слова и не содержать пробелов. Это три проверки, но все они про одно и то же качество: «slug пригоден для URL и соответствует правилу».
Вот тот же пример со slug, но уже в AssertJ-стиле:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugFormatAssertJTest {
@Test
void slug_is_readable_for_url() {
// Actual: результат, который проверяем
String slug = "java-basics";
// Одна цепочка = один смысл: "slug пригоден для URL"
assertThat(slug)
.isEqualTo("java-basics") // точное значение
.startsWith("java") // нужный префикс
.doesNotContain(" "); // отсутствие пробелов
}
}
Здесь тест читается как маленький рассказ: «slug равен…, начинается с…, не содержит…». Сразу видно, что все проверки про одно.
Теперь пример с числами. В ContentHub у вложений могут быть ограничения: например, «не больше 3 вложений на статью» (точная цифра сейчас не важна; важно, что есть лимит). Числовые проверки на JUnit’е часто превращаются в assertTrue(count <= 3), и это звучит как «мы где-то сравнили что-то с чем-то». AssertJ даёт более читабельную форму.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AttachmentLimitsTest {
@Test
void attachment_count_is_within_limit() {
// Пример фактического количества вложений
int attachmentCount = 2;
// Проверяем смысл (ограничение), а не "формулу"
assertThat(attachmentCount)
.isPositive() // не отрицательное число
.isLessThanOrEqualTo(3); // не превышает лимит
}
}
Тут уже видно, что мы проверяем не «формулу», а смысл: число положительное и не превышает лимит.
И ещё один маленький пример — boolean. Иногда в бизнес-коде есть флаг, и вы хотите прочитать тест буквально как «должно быть истинно». AssertJ позволяет это сделать без ощущения, что вы читаете математику.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class BooleanAssertionsTest {
@Test
void article_can_be_published_flag_is_true() {
// Actual: флаг из бизнес-логики (в примере — просто константа)
boolean canBePublished = true;
// Assert: читается почти как предложение
assertThat(canBePublished).isTrue();
}
}
Да, это выглядит тривиально. Но когда таких проверок много, «человеческий» стиль начинает экономить вам внимание. А внимание — самая дефицитная валюта программиста (после кофе).
Важно не переборщить: в одну fluent-цепочку стоит объединять только связанные по смыслу проверки. Если вы начали проверять slug, потом внезапно статус статьи, а потом ещё и количество вложений — это уже три разных смысла, и цепочка превращается в кашу. AssertJ даёт удобство, но не отменяет здравый смысл.
5. .as(...) и понятные падения
Когда тест падает, вы обычно видите две вещи: stacktrace и сообщение об ошибке. Stacktrace в тестах редко радует душу, поэтому «качество жизни» сильно зависит от того, насколько понятное сообщение вы получили.
AssertJ позволяет добавить описание проверки через .as(...). Это не меняет логику, но делает падение более предметным: вместо «expected X, but was Y» вы увидите, что именно проверяли и в каком контексте.
Представим, что мы проверяем статус новой статьи. В ContentHub по сценарию новая статья создаётся как DRAFT. Даже если сам тест пока совсем игрушечный, описание делает его ближе к предметной области.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleStatusTest {
@Test
void new_article_starts_as_draft() {
// Actual: статус, который вернул код (в примере — просто строка)
String status = "DRAFT";
// Описание помогает понять падение: что именно мы проверяли
assertThat(status)
.as("статус новой статьи")
.isEqualTo("DRAFT");
}
}
Смысл .as(...) особенно проявляется, когда тест становится длиннее и в нём появляется несколько проверок. Если одна из них упадёт, вы сразу увидите в сообщении «статус новой статьи», а не будете гадать, что это была за переменная status и почему она вообще здесь появилась.
Есть тонкость: описание должно быть предметным. Если вы напишете "test" или "check", это не поможет. Хорошее описание отвечает на вопрос «что это за значение в домене»: статус статьи, slug для URL, лимит вложений, имя категории и так далее.
6. AssertJ на примерах ContentHub
Язык проверок лучше тренировать на «живых» примерах домена, а не на безымянных foo/bar. Поэтому возьмём три очень типичные вещи из ContentHub: slug, статус статьи и проверку простого ограничения.
Пример: минимальный SlugService и тест
Чтобы пример был самодостаточным, сделаем крошечный сервис. Он не «идеальный», он учебный: переводит строку в нижний регистр, обрезает края и заменяет пробелы на дефисы. Главное, что у нас есть объект, который возвращает результат, и этот результат можно осмысленно проверять.
class SlugService {
String toSlug(String title) {
// Учебная реализация: нормализуем регистр и делаем "URL-похожую" строку
return title.toLowerCase()
.trim()
.replace(" ", "-");
}
}
А теперь тест. Обратите внимание: мы не ограничиваемся isEqualTo, а проверяем «свойства пригодности для URL». В этом и сила fluent-цепочек: один смысл — несколько условий.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceTest {
@Test
void toSlug_makes_lowercase_and_uses_hyphens() {
// Arrange: создаём сервис
SlugService slugService = new SlugService();
// Act: получаем фактический результат (actual)
String slug = slugService.toSlug("Java Basics");
// Assert: проверяем несколько связанных требований к slug
assertThat(slug)
.as("slug статьи для URL")
.isEqualTo("java-basics") // ожидаемый формат
.doesNotContain(" ") // пробелов быть не должно
.doesNotStartWith("-"); // защитимся от странного префикса
}
}
Здесь мы уже делаем чуть больше, чем простое сравнение на равенство. И это полезно: если реализация сервиса поменяется (например, кто-то забудет trim()), тест поймает проблему по смыслу, а не только по «совпадению строки».
Пример: статус статьи как часть правила
Пусть у нас есть enum статусов (в проекте он точно будет, но сейчас сделаем маленькую заготовку):
enum ArticleStatus {
// Возможные состояния статьи в домене
DRAFT,
IN_REVIEW,
PUBLISHED,
REJECTED,
ARCHIVED
}
И пусть новая статья по умолчанию создаётся как DRAFT. Снова: пример учебный, но близкий к реальности.
class Article {
// Храним статус как часть состояния сущности
private final ArticleStatus status;
Article(ArticleStatus status) {
// В реальном проекте тут обычно ещё есть инварианты и валидация
this.status = status;
}
ArticleStatus getStatus() {
// Геттер — то, что будет использовать тест (actual берём отсюда)
return status;
}
}
Тест с AssertJ остаётся простым и читаемым:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleTest {
@Test
void new_article_is_draft() {
// Arrange/Act: создаём статью с известным статусом
Article article = new Article(ArticleStatus.DRAFT);
// Assert: сравниваем с ожидаемым значением из домена
assertThat(article.getStatus())
.as("статус статьи при создании")
.isEqualTo(ArticleStatus.DRAFT);
}
}
Почему это хороший пример именно для лекции про AssertJ? Потому что он показывает ключевой принцип: assertThat получает actual — то, что вернул код, — а дальше мы формулируем ожидание в терминах домена, а не в стиле «где-то там сравнили две штуки».
Пример: простое ограничение как читаемая проверка
Возьмём ограничение по размеру файла, но без файловой системы и без I/O. Пусть метод принимает размер и говорит, допустим ли он. Условно: до 5 МБ можно.
class AttachmentValidationService {
boolean isSizeAllowed(long sizeBytes) {
// Верхняя граница размера (5 МБ)
long max = 5 * 1024 * 1024;
// Правило: размер должен быть не больше лимита
return sizeBytes <= max;
}
}
Тест и здесь получается читабельнее, если мы проверяем не «формулу», а факт:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AttachmentValidationServiceTest {
@Test
void size_is_allowed_when_under_limit() {
// Arrange
AttachmentValidationService service = new AttachmentValidationService();
// Act: проверяем маленький размер
boolean allowed = service.isSizeAllowed(1024);
// Assert: хотим получить "да"
assertThat(allowed)
.as("маленький файл должен быть разрешён")
.isTrue();
}
}
Да, кто-то скажет: «Ну тут же можно было assertTrue(allowed)». Можно. Но, во-первых, мы тренируем единый стиль. Во-вторых, .as(...) делает падение осмысленным. И в-третьих, привычка писать assertThat везде потом даёт вам ровный, однородный набор тестов.
7. AssertJ в Arrange–Act–Assert
Иногда кажется, что если мы сделаем assertions красивыми, тест автоматически станет хорошим. Почти да, но есть нюанс: хороший тест — это не только «что мы проверяем», но и «как мы к этому пришли». Поэтому за AAA мы по-прежнему держимся, а AssertJ используем как лучший вариант для третьей части — Assert.
Посмотрите на тест ниже: он разделён комментариями на три смысловых блока. Это не обязательно, но новичкам помогает не теряться. А AssertJ-цепочка в конце делает проверку компактной и собранной.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SlugServiceAaaTest {
@Test
void toSlug_generates_url_friendly_value() {
// Arrange
SlugService slugService = new SlugService();
// Act
String slug = slugService.toSlug(" Spring Boot ");
// Assert
assertThat(slug)
.as("slug должен подходить для URL")
.isEqualTo("spring-boot")
.doesNotContain(" ");
}
}
Что здесь важно методически: тест читается сверху вниз как сценарий. Вы не прыгаете глазами между assertTrue, assertFalse и assertEquals, пытаясь собрать общую картину. Вы сразу видите, что результат slug должен удовлетворять нескольким связанным свойствам, и они лежат рядом — одной цепочкой.
Кстати, это ещё один скрытый плюс fluent-цепочек: они помогают заметить, что вы начали проверять слишком много. Если цепочка выросла до 12 условий, значит, либо вы проверяете несколько разных смыслов сразу (пора делить на несколько assertions), либо сам тест стал слишком широким (пора упрощать сценарий).
8. Шпаргалка по проверкам
Когда вы начинаете пользоваться AssertJ, мозг сначала просит «список всех методов». Это нормальная реакция, но бесполезная: библиотека большая, и всё равно вы не запомните всё сразу. Гораздо полезнее держать под рукой небольшой набор «ежедневных» проверок, которые покрывают 80% случаев на старте курса.
| Тип результата | Примеры проверок в AssertJ | Мини-пример |
|---|---|---|
| Строки | isEqualTo, contains, startsWith, endsWith, doesNotContain, isBlank, isNotBlank | assertThat(slug).startsWith("java").doesNotContain(" "); |
| Числа | isZero, isPositive, isNegative, isLessThan, isLessThanOrEqualTo, isGreaterThan, isBetween | assertThat(count).isPositive().isLessThanOrEqualTo(3); |
| boolean | isTrue, isFalse | assertThat(allowed).isTrue(); |
| null / not null | isNull, isNotNull | assertThat(article).isNotNull(); |
Эта таблица — не «истина в последней инстанции», а стартовый набор, с которым уже можно писать понятные тесты. Всё остальное добавляйте постепенно, по мере усложнения проверок, чтобы обучение не превращалось в энциклопедию.
9. Типичные ошибки при переходе на AssertJ
Ошибка №1: в assertThat(...) кладут expected вместо actual.
Это классическая путаница после assertEquals(expected, actual). В AssertJ всё начинается от факта: вы проверяете то, что получилось. Если вы положили expected в assertThat, тест может стать бессмысленным — или, что хуже, «всегда зелёным», потому что вы фактически проверяете ожидание само на себя. Лечится просто: проговорите себе вслух «assertThat — это “утверждаю, что вот ЭТО значение…”».
Ошибка №2: в одну цепочку склеивают несвязанные по смыслу проверки.
Fluent-цепочка не означает «пишем всё подряд одним поездом». Если вы проверяете slug, статус и лимит вложений в одной цепочке, вы не делаете тест компактнее — вы делаете его нечитаемым. Цепочка хороша, когда все условия описывают один и тот же аспект качества одного результата. Если смыслов несколько, лучше сделать несколько отдельных assertions, каждый со своим .as(...).
Ошибка №3: .as(...) используют как декоративную наклейку "test".
.as("test") или .as("check something") не помогает. Описание должно быть доменным: «slug статьи для URL», «статус новой статьи», «лимит количества вложений». Тогда, когда тест упадёт, вы увидите в сообщении именно тот смысл, который хотели защитить. Это особенно важно в большом наборе тестов, где похожих проверок много.
Ошибка №4: AssertJ используют как “просто замену assertEquals”.
Если вы пишете assertThat(x).isEqualTo(y) и всё — вы уже сделали шаг вперёд по читаемости, но не использовали сильную сторону библиотеки. AssertJ хорош тем, что позволяет проверять свойства результата. Например, для slug полезно проверять не только равенство, но и отсутствие пробелов, нормальный формат, отсутствие странных префиксов. То есть вы фиксируете поведение, а не только конкретное значение.
Ошибка №5: тест становится “слишком умным” и начинает проверять лишнее.
У AssertJ очень много методов, и есть соблазн проверить всё: и startsWith, и endsWith, и matches, и длину, и ещё десять условий «на всякий случай». В результате тест становится хрупким: меняется формат вывода — и падает половина набора тестов, хотя реальный риск не вырос. Хорошая проверка — это та, которая защищает сценарий от реального дефекта, а не та, которая «максимально строгая ради строгости».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ