JavaRush /Курсы /Spring Test /Параметризованные тесты: границы и матрицы

Параметризованные тесты: границы и матрицы

Spring Test
2 уровень , 3 лекция
Открыта

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. Тогда матрица выглядит так:

hasTitle
hasBody
canSubmit
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, а не магический язык описания бизнеса.

1
Задача
Spring Test, 2 уровень, 3 лекция
Недоступна
Набор допустимых и недопустимых заголовков через @ValueSource
Набор допустимых и недопустимых заголовков через @ValueSource
1
Задача
Spring Test, 2 уровень, 3 лекция
Недоступна
Граничные длины заголовка через @CsvSource
Граничные длины заголовка через @CsvSource
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ