1. Введение
Параметризованные тесты часто продают как «способ писать меньше кода». Это правда, но не главное. Их настоящая ценность в другом: они помогают явно показать таблицу правил или граничные значения, не пряча логику в десятках однотипных методов. Это особенно полезно в backend‑правилах, где одно и то же правило гоняется на наборе входов, и важно видеть весь набор целиком.
Представьте простое правило из мира ContentHub: «статью можно отправить на ревью, только если есть непустой заголовок и непустой текст». Очень быстро захочется проверить минимум четыре комбинации: есть/нет заголовка × есть/нет текста. Можно написать четыре тест‑метода. А можно написать один параметризованный тест и таблицей перечислить все комбинации. Во втором случае получится не просто тест, а мини‑документация правила.
Есть и важный психологический бонус: когда перед вами таблица входов, гораздо легче заметить пропуск. Например, вы проверили 0, 1, 100, но забыли 101 — классика в духе «ой, а что на границе?». Параметризация дисциплинирует: раз уж делаем матрицу, давайте делать её честно.
2. Что такое @ParameterizedTest: один метод — много запусков
Если @Test означает «запусти этот метод один раз», то @ParameterizedTest означает «запусти этот метод несколько раз, подставляя разные значения параметров». Снаружи это выглядит как один метод, но для JUnit 6 это серия отдельных прогонов. И это важный момент: если упадёт один набор данных, вы увидите, какой именно набор всё сломал.
Ниже — минимальный пример, который показывает саму механику. Обратите внимание: тестовый метод теперь принимает параметр String title, а значения приходят из аннотации‑источника.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TitleRuleParameterizedTest {
@ParameterizedTest
@ValueSource(strings = {"Java", "JUnit basics", "ContentHub"})
void acceptsNonBlankTitles(String title) {
// Проверяем правило на каждом значении из ValueSource: каждый элемент — отдельный прогон
assertTrue(isAcceptableTitle(title));
}
private boolean isAcceptableTitle(String title) {
// Явно фиксируем ожидаемое поведение правила: null и "пусто/пробелы" не подходят
return title != null && !title.isBlank();
}
}
Техническое замечание, которое экономит время: параметризованные тесты лежат в отдельном модуле JUnit 6. В зависимости от шаблона проекта может понадобиться зависимость junit-jupiter-params. В Spring Boot‑проекте она обычно уже приходит через тестовый стартер, а вот в «голом» Gradle‑проекте легко поймать ситуацию «аннотация не найдена» и решить, что вы всё сломали. Нет, не сломали — просто не подключили нужную зависимость.
Чтобы закрепить идею «это несколько запусков», удобно представить всё так:
flowchart TD
A["@ParameterizedTest method"] --> B["Набор данных #1"]
A --> C["Набор данных #2"]
A --> D["Набор данных #3"]
B --> E["Запуск теста как отдельный прогон"]
C --> F["Запуск теста как отдельный прогон"]
D --> G["Запуск теста как отдельный прогон"]
3. @ValueSource: один параметр, минимум церемоний
@ValueSource — самый простой способ параметризации: один тип параметра, один столбец данных. Он идеально подходит для проверок вроде «вот список валидных значений» или «вот набор чисел, которые должны пройти правило». В таком тесте очень хорошо видно: мы проверяем одно поведение и просто прогоняем его на нескольких примерах.
Давайте сделаем пример чуть ближе к ContentHub. Допустим, у нас есть правило для заголовка: он не должен быть пустым и не должен состоять только из пробелов. Мы хотим прогнать несколько нормальных заголовков.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ArticleTitleValueSourceTest {
// name делает вывод прогонов читаемым: в отчёте будет видно, на каком title упали
@ParameterizedTest(name = "[{index}] title=''{0}'' is acceptable")
@ValueSource(strings = {"JUnit basics", " Clean title ", "Spring? later 🙂"})
void acceptsNonBlankTitles(String title) {
// Здесь форма проверки одинакова для всех входов — это хороший кейс для параметризации
assertTrue(hasVisibleTitle(title));
}
private boolean hasVisibleTitle(String title) {
// Правило: нельзя null и нельзя строку, состоящую только из пробелов
return title != null && !title.isBlank();
}
}
Здесь важны два наблюдения. Во‑первых, мы добавили name в @ParameterizedTest — это делает вывод прогонов читаемым. Если один из кейсов упадёт, вы сразу увидите в списке запусков конкретный title. Во‑вторых, мы не усложняем Arrange: параметризация особенно хорошо работает там, где форма сценария и форма проверки одинаковы для всех входов.
Если нужно протестировать и валидные, и невалидные значения, чаще всего разумно разделить это на два параметризованных теста: один проверяет assertTrue(...) для валидных примеров, другой — assertFalse(...) для невалидных. Да, можно утащить ожидаемый результат в параметр и сделать всё одним методом, но читаемость у новичков от этого обычно только страдает.
С @ValueSource у нас одна ось входов и один тип ожидания, поэтому позитивные и негативные наборы часто читаются лучше по отдельности. В @CsvSource задача меняется: входов становится несколько, и перед нами уже таблица решений. Здесь отдельный столбец expected обычно не усложняет тест, а, наоборот, делает правило наглядным.
4. @CsvSource: маленькая таблица правил в одном тесте
@CsvSource — отличный инструмент, когда параметров несколько и правило удобно представить табличкой. По сути, вы пишете мини‑набор данных прямо в аннотации: строки — это тестовые случаи, столбцы — параметры, а последним столбцом часто идёт expected. Это как раз тот момент, когда тест начинает выглядеть как спецификация правила.
Возьмём правило «статья может быть отправлена на ревью, только если есть заголовок и есть текст». Если упростить, входы можно представить как два флага: hasTitle и hasBody. Тогда матрица выглядит так:
|
|
|
|---|---|---|
| true | true | true |
| true | false | false |
| false | true | false |
| false | false | false |
И теперь мы буквально превращаем эту таблицу в @CsvSource:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SubmitRuleCsvSourceTest {
// В имени прогона показываем входы и ожидаемый результат — это ускоряет диагностику падений
@ParameterizedTest(name = "[{index}] title={0}, body={1} -> {2}")
@CsvSource({
"true, true, true",
"true, false, false",
"false, true, false",
"false, false, false"
})
void checksSubmissionMatrix(boolean hasTitle, boolean hasBody, boolean expected) {
// Одна форма проверки на всю таблицу: expected сравниваем с фактическим результатом правила
assertEquals(expected, canBeSubmitted(hasTitle, hasBody));
}
private boolean canBeSubmitted(boolean hasTitle, boolean hasBody) {
// Упрощённое правило для примера: отправлять можно только при наличии и заголовка, и текста
return hasTitle && hasBody;
}
}
Обратите внимание на несколько «мелочей», которые на деле вовсе не мелочи. Мы используем assertEquals(expected, ...), потому что у нас один тест и один формат проверки для всех строк. Мы даём понятное имя каждому прогону через name, чтобы при падении не пришлось гадать, какая строка сломалась. И мы держим таблицу короткой: если она занимает два экрана, возможно, правило уже стоит тестировать иначе — или разбить на несколько таблиц.
Кстати, CsvSource — это не только про boolean. Очень часто так тестируют граничные значения: length, expected; sizeBytes, expected; statusFrom, statusTo, expected.
5. Граничные значения: где прячутся баги
Граничные значения — это места, где правило «переключается» из разрешённого в запрещённое. В реальном проекте дефекты чаще всего вылезают именно там, потому что разработчик мыслит «в целом» — например, «до 5 МБ», — а компьютер мыслит точными числами: 5 МБ — это сколько байт? включительно или нет? что с нулём? Тесты на границах — это способ сделать правило конкретным и недвусмысленным.
В ContentHub есть вложения (ArticleAttachment), и почти наверняка там будут ограничения по размеру. Файловую систему пока не трогаем — это позже и в других слоях, — но правило «размер в байтах должен быть > 0 и <= max» прекрасно тестируется в чистой Java. Сконцентрируемся на границе.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AttachmentSizeBoundaryTest {
// Верхняя граница правила (в байтах): берём ровно её и +1 для проверки переключения
private static final long MAX_BYTES = 5_000_000L;
@ParameterizedTest(name = "[{index}] size={0} -> allowed={1}")
@CsvSource({
// 0 — запрещено: "пустой файл нельзя"
"0, false",
// 1 — минимально допустимый размер
"1, true",
// MAX — верхняя допустимая граница (включительно)
"5000000, true",
// MAX+1 — первый запрещённый размер
"5000001, false"
})
void checksAttachmentSizeBoundary(long sizeBytes, boolean expected) {
// Одна проверка на все граничные кейсы: сравниваем ожидаемое и фактическое
assertEquals(expected, isAllowedSize(sizeBytes));
}
private boolean isAllowedSize(long sizeBytes) {
// Явно фиксируем включительность верхней границы: <= MAX_BYTES
return sizeBytes > 0 && sizeBytes <= MAX_BYTES;
}
}
Здесь мы сделали четыре кейса, и это почти канонический минимум для границы. 0 показывает «пустой файл нельзя», 1 — самый маленький допустимый размер, MAX_BYTES — верхнюю допустимую границу, а MAX_BYTES + 1 — первый запрещённый размер. Даже если вы вообще не знаете предметную область, таблица объясняет правило лучше многих комментариев.
Да, иногда границы сложнее, чем «плюс один». Например, длина строки с нормализацией, количество вложений на статью или значения page/size в API. Но привычка начинать с простого «смотрим на краях» — очень здоровая. И параметризованные тесты здесь идеальны: вы буквально перечисляете края как набор строк.
6. Матрица переходов статусов статьи
Один из самых вкусных кейсов параметризованных тестов в backend‑домене — матрицы переходов состояний. В ContentHub у статьи фиксированные статусы: DRAFT, IN_REVIEW, PUBLISHED, REJECTED, ARCHIVED. Даже если вы пока не знаете весь workflow, сама идея «из какого статуса в какой можно перейти» отлично ложится в таблицу.
Сделаем крошечную учебную версию политики переходов. В реальном проекте она будет богаче, но нам здесь важна форма теста, а не полнота бизнес‑логики.
enum ArticleStatus {
DRAFT, IN_REVIEW, PUBLISHED, REJECTED, ARCHIVED
}
class PublicationPolicy {
boolean canTransition(ArticleStatus from, ArticleStatus to) {
// Учебное правило: разрешаем ровно один переход, чтобы показать форму матрицы в тесте
return from == ArticleStatus.DRAFT && to == ArticleStatus.IN_REVIEW;
}
}
И теперь — параметризованный тест. JUnit умеет конвертировать строки из CsvSource в enum по имени, то есть "DRAFT" превратится в ArticleStatus.DRAFT. Это удобно, потому что таблица остаётся читаемой.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PublicationPolicyMatrixTest {
// Политика — тот самый "юнит", который мы проверяем таблицей переходов
private final PublicationPolicy policy = new PublicationPolicy();
@ParameterizedTest(name = "[{index}] {0} -> {1} allowed={2}")
@CsvSource({
// Разрешённый переход
"DRAFT, IN_REVIEW, true",
// Запрещённый переход (в учебном правиле)
"DRAFT, PUBLISHED, false",
// Ещё один запрещённый переход
"PUBLISHED, ARCHIVED, false"
})
void checksTransitions(ArticleStatus from, ArticleStatus to, boolean expected) {
// Сравниваем ожидаемое разрешение перехода с фактическим решением policy
assertEquals(expected, policy.canTransition(from, to));
}
}
Почему я здесь намеренно оставил матрицу маленькой? Потому что на нашем этапе курса мы учимся технике, а не пытаемся покрыть весь workflow. Полная таблица переходов появится позже, когда возникнет «настоящая» задача и мы будем тестировать бизнес‑правила предметно. Сейчас вам важно унести мысль: таблица переходов — отличный кандидат на @CsvSource, а тест в таком виде становится понятным документом «что можно, что нельзя».
7. Когда параметризация вредна
Параметризованные тесты — инструмент, а не религия. Иногда они действительно улучшают тест, а иногда делают его хуже, потому что вы пытаетесь засунуть в таблицу то, что таблицей не является. Если у разных кейсов принципиально разный Arrange — например, в одном случае вы создаёте объект так, в другом иначе, а в третьем ещё и собираете какую‑нибудь коллекцию, — единый параметризованный тест превращается в метод с кучей if и «умных» ветвлений. Такой тест почти всегда хуже, чем два‑три обычных.
Есть простой «нюхательный тест» — простите, профессиональный термин, звучит так, будто тесты надо понюхать. Если внутри параметризованного метода вам хочется писать что‑то вроде if (expected) { ... } else { ... }, скорее всего, вы уже пытаетесь объединить разные сценарии. Параметризованный тест хорош там, где форма проверки одинакова, а различаются только входные значения. Как только начинает различаться сам сценарий, подготовка или ожидаемый тип результата — например, то возвращаем значение, то ждём исключение, — чаще всего лучше разнести это по разным тестам.
И ещё одна причина не параметризовать всё подряд — читаемость. Иногда два отдельных теста с хорошими именами читаются быстрее, чем одна таблица, даже если таблица и «экономит строки». В тестах, как ни странно, экономия строк редко бывает главным критерием.
8. Типичные ошибки при параметризованных тестах
Ошибка №1: не подключён модуль параметризованных тестов, и аннотации “не находятся”.
В JUnit параметризованные тесты живут не в самом базовом минимуме, а в отдельной части — в зависимости от сборки это отдельный артефакт. Если IDE подсвечивает @ParameterizedTest как неизвестный символ или тесты не запускаются, это часто не «сломанный JUnit», а банально отсутствующая тестовая зависимость.
Ошибка №2: параметризованный тест превращается в “комбайн” со сложным Arrange и ветвлениями.
Параметризация хороша, когда сценарий одинаковый, а входы разные. Когда вы начинаете внутри теста условно создавать разные объекты, включать разные флаги, менять подготовку данных и при этом всё ещё пытаетесь удержать один метод — читать такой тест становится тяжелее, чем несколько обычных @Test. В итоге вы экономите 10 строк, но тратите 30 минут на понимание падения.
Ошибка №3: таблица данных слишком большая и превращается в простыню.
@CsvSource удобно читать, пока это небольшая матрица — как у светофора: красный, жёлтый, зелёный. Когда таблица разрастается до 40 строк, мозг начинает по ней «проскальзывать», и смысл теряется. В таких случаях лучше разделить матрицу по контекстам, вынести часть тестов в отдельные @Nested‑классы или просто написать несколько обычных тестов с хорошими именами.
Ошибка №4: нечитаемый вывод запусков, потому что не задано имя параметризованного теста.
Без name = ... JUnit покажет прогоны и так, но в отчёте они нередко выглядят слишком одинаково, особенно если параметры сложные. Маленькая привычка писать @ParameterizedTest(name = "[{index}] ...") экономит кучу времени при диагностике. Это почти как подписывать контейнеры на кухне: можно и без этого, но однажды вы всё‑таки посолите чай.
Ошибка №5: проблемы с CSV‑синтаксисом и “странные” значения из‑за пробелов/кавычек/запятых.
CsvSource выглядит дружелюбно, пока вы передаёте true/false и числа. Как только начинаются строки с запятыми, кавычками или пробелами, внезапно можно получить совсем не те значения, которые ожидались. В таких случаях нужно аккуратно экранировать строки и помнить, что это всё‑таки CSV, а не магический язык описания бизнеса.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ