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); |
| Булеві значення | 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, і довжину, і ще десять умов «про всяк випадок». У результаті тест стає крихким: змінюється формат виводу — і падає половина набору тестів, хоча реальний ризик не зріс. Хороша перевірка — це та, що захищає сценарій від реального дефекту, а не та, що «максимально сувора заради суворості».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ