Философия тестирования в Kotlin: Expect/Actual, , Kotest, Junit, Unit-тесты и природа Test Doubles

Глубокое погружение в философский фундамент тестирования на Kotlin: что означают expect/actual, в чём разница между Unit и JUnit тестами, а также концептуальные роли mocks, stubs, fakes и spies. Понимание тестов как контролируемых симуляций реальности.

20мин чтенияKotlin
Share:

1. Введение: Почему тесты - это не проверка, а модель

Эта статья открывает серию «Android под капотом: Тестирование без иллюзий». Семь частей - семь углублений, от фундамента до системной архитектуры, от синтаксиса до этики. Перед вами первая: философская база. Она нужна, чтобы все остальные статьи не висели в воздухе.

Если вы давно тестируете - это напоминание о том, зачем вы это делаете. Если вы только начинаете - это фундамент, без которого всё остальное превращается в механическое повторение шаблонов по tutorial-ам и “Best Practice”.

Тесты часто представляют как механизм проверки: мол, есть код, и нужно проверить, правильно ли он работает. Но программное обеспечение - не прибор. У нас нет датчиков, которыми можно измерить корректность. У нас есть только модели поведения. И тесты - это не проверка, а реализация этих моделей в виде исполняемого кода.

Когда мы пишем тест, мы создаём вторую вселенную, то есть виртуального пользователя. Она похожа на настоящую, но лучше контролируема. В ней время стоит на месте, зависимости делают только то, что мы им разрешаем, а сама система ведёт себя предсказуемо. Это не копия продакшена - это его реконструкция. Более того:это реконструкция, основанная не на фактах, а на намерениях. Мы тестируем не то, как система работает - а то, как мы хотим, чтобы она работала.

Вот почему тесты редко умирают от багов. Они умирают от изменения ожиданий. Контракт меняется - тест падает, частая практика не так ли?). Не потому, что код стал хуже. А потому, что зеркало, в которое мы смотрели, больше не совпадает с формой оригинала.

Тест - это артефакт доверия. Он фиксирует границы допустимого, которые мы согласились считать истиной. КаждыйassertEquals(expected, actual) - это не факт, это акт утверждения. Он говорит: мы согласны, что если actual == expected, то всё хорошо. Это соглашение. Это договор.

Kotlin хорошо подходит для тестирования, потому что даёт те же инструменты, что и для основного кода: лямбды, DSL, расширения, корутины, строгую типизацию. Тесты на Kotlin не ощущаются как отдельный язык - они пишутся теми же средствами, в той же парадигме. Нет ощущения «тест - это вторая лига». Благодаря компактному синтаксису, читаемости и отсутствию лишнего шаблонного кода, тест выглядит как часть архитектуры, а не как надстройка над ней.

Во всей серии примеры будут на Kotlin - не потому что «так модно», а потому что это стандарт Android-разработки и полноценный язык для продакшена, независимо от платформы. Kotlin уже используется в Spring, Ktor, Compose, KMP - и везде он одинаково хорош для тестов.

2. Базис: Лексикон тестирования

Прежде чем углубляться в механику и архитектуру тестов, нужно зафиксировать базовый словарь. Без него любые рассуждения - словно попытка строить архитектуру без понятий “стена”, “связь” и “опора”. Ниже - не полный глоссарий, а именно тот минимум, на который будет опираться вся серия.

Assertion

Assertion - это программное утверждение, выражающее ожидание. Если оно не выполняется - тест считается неуспешным. В большинстве фреймворков assertion реализован как функция, выбрасывающая исключение при расхождении:

assertEquals(expected, actual)
assertTrue(user.isLoggedIn)

Важно понимать: assertion не описывает поведение - он фиксирует его. Это финальная точка сценария, где тест явно заявляет: вот то, что я считаю допустимым. Всё остальное - лишь подготовка к этому моменту.

Test Case

Test Case - это единичный сценарий тестирования. Он состоит из трёх фаз: подготовка контекста (arrange), выполнение действия (act) и проверка результата (assert). В Kotlin это обычно функция с аннотацией @Test.

@Test
fun loginFailsWithInvalidPassword() {
    val auth = AuthService(FakeUserRepo())
    val result = auth.login("admin", "wrong")
    assertFalse(result.success)
}

Test Case должен быть изолированным и воспроизводимым. Если тест зависит от состояния внешнего окружения - он быстро теряет свою надёжность.

Test Suite

Test Suite - это логическая группа тестов, объединённых по какому-либо признаку: модуль, слой архитектуры, бизнес-функциональность. Обычно это класс, файл или структура DSL. Назначение suite-а - собрать родственные кейсы и запускать их совместно, например, при CI-сборке или нагрузочном прогоне.

Хорошо организованный suite - это не просто папка с тестами. Это карта покрытия. Он показывает, где есть тесты, а где - только надежда на интуицию разработчиков.

Test Runner

Test Runner - это механизм, отвечающий за обнаружение и исполнение тестов. Он обеспечивает инфраструктурный цикл: инициализация, выполнение, репортинг. В JUnit 5 runner разделён на модули: Platform, Jupiter и Vintage. Kotlin-тесты в JVM-проектах чаще всего запускаются через Gradle (или build system IDE), где runner интегрирован в пайплайн.

Хороший runner не замечается. Плохой - ломает вам отладку, кеширует устаревшие состояния или игнорирует падения.

Test Double

Test Double - это подставной объект, который заменяет настоящую зависимость в тесте. Это нужно для того, чтобы не вызывать реальную базу, не отправлять настоящие письма и не ждать сетевых ответов. С такими объектами тест становится изолированным и управляемым.

Существует несколько типов Test Double-ов - и каждый из них решает свою задачу.

Stub используется, когда важно просто вернуть фиксированный ответ. Он не запоминает, что у него спрашивали, и не интересуется, зачем. Его задача - быть стабильным фоном. Например, когда нужно всегда возвращать текущую дату или результат запроса.

Mock применяется, когда важно не только то, что вернулось, но и то, как с этим объектом взаимодействовали. Сколько раз вызвали метод, с какими параметрами, в каком порядке. Mock нужен там, где поведение зависит от побочных эффектов: уведомлений, логов, внешних вызовов.

Fake - это рабочая, но упрощённая версия настоящей реализации. Например, фейковая база, которая хранит данные в памяти, а не на диске. В отличие от stub-ов и mock-ов, фейк умеет «жить» - накапливать состояние, вести себя как настоящая система, но без всей тяжеловесности.

Spy похож на mock, но вместо имитации он действительно выполняет действия, как настоящий объект. При этом он фиксирует, что и когда вызывалось. Spy нужен, когда поведение важно сохранить, но и проверить взаимодействие тоже необходимо.

Dummy — самый простой и самый бесполезный на первый взгляд Test Double. Он ничего не делает, ничего не возвращает и никак не влияет на поведение теста. Его задача — просто быть. Dummy нужен в тех случаях, когда метод требует обязательного параметра, но в тесте этот параметр не играет никакой роли. Например, когда нужно передать объект логгера или callback, но сам лог не важен. Это форма синтаксического шума, которую мы осознанно заглушаем.

Test Double - это техника, которая позволяет тестировать сложные системы по частям, без запуска всего окружения. И если код не поддаётся тестированию с такими объектами - проблема обычно не в тестах, а в архитектуре.

Unit vs Integration

Unit-тест - это тест, который полностью контролирует окружение. Все внешние зависимости заменяются на подставные объекты (Test Doubles), состояние стабильно, внешних эффектов нет. Такой тест проверяет поведение конкретной единицы кода: функции, метода, класса - в изоляции.

Integration-тест допускает реальные зависимости: базу данных, файловую систему, сеть. Его цель - проверить, как компоненты взаимодействуют друг с другом в условиях, приближенных к боевым.

Разделение между ними не бинарное. Это шкала. Есть тесты, которые используют, например, настоящий генератор UUID или текущее время - формально это уже не “чистый” Unit. Но такие компромиссы допустимы, если они осознаны. Главное - понимать, что вы контролируете, а что - нет.

Критерий здесь не в размере функции или скорости выполнения, а в уровне изоляции. Чем больше вы контролируете, тем ближе тест к Unit. Чем больше полагаетесь на реальные зависимости - тем ближе он к Integration. Это не про формат, это про доверие.

Тест как артефакт

Тест - это не костыль и не подстраховка. Это полноценный исполняемый артефакт, живущий в том же репозитории, что и основной код. Он проходит через компиляцию, участвует в CI/CD пайплайне, ломается при неудачном рефакторинге и требует сопровождения. Если продакшен-код описывает как работает система, то тест - что считается допустимым в её поведении.

Хороший тест не объясняет. Он формализует. И в этом его ценность: он позволяет автоматизировать договор между человеком и системой.

3. Концепция: Что вообще мы тестируем?

Если упростить до предела, то тест - это запуск части системы в контролируемом контексте. Он не доказывает, что система работает правильно. Он подтверждает, что в заданных условиях она ведёт себя определённым образом. Мы не тестируем “истину”. Мы тестируем поведение в симулированной среде.

Симуляция, а не доказательство

Распространённая ошибка - считать, что наличие тестов означает корректность системы. Это не так. Тест - это не формальное доказательство, а ограниченная модель. Он покрывает конкретный сценарий, с заданными входами и ожидаемыми выходами.

Если поведение системы изменилось, но тесты всё ещё проходят - это не значит, что поведение правильное. Это значит, что тест об этом не знает.

Например: раньше isPremiumUser возвращал true, если у пользователя была активная подписка. Потом добавили гратис-период, и теперь true возвращается и в первые 7 дней бесплатно. Логика поменялась, но тесты остались прежними - и продолжают проходить. Только теперь они подтверждают совсем не то, что раньше. Просто не знают, что смысл true изменился.

Тест проверяет не всю систему, а конкретный срез: функцию, модуль, цепочку вызовов. Он фиксирует то, что в момент написания считалось «допустимым». Если позже меняется бизнес-логика, интерфейс или требования - тест становится устаревшим. Это не баг, это свойство теста как инструмента.

Контролируемое окружение

Настоящая система работает в условиях, которые невозможно воспроизвести на 100%: флуктуации сетевых задержек, состояние внешних API, время, потоки, гонки, кеши. Тест же создаёт контролируемое окружение, где всё поведение заранее известно и управляется вручную: от стабов и фейков до фиктивного времени и UUID.

Разница принципиальна. В production ошибки могут быть случайными. В тестах они - либо детерминированы, либо не обнаруживаются вовсе. Поэтому тест никогда не может гарантировать “всё работает”. Он может только сказать: в этих условиях - да.

Уровень доверия

Каждый тест даёт разработчику определённый уровень уверенности. Но важно понимать, откуда именно эта уверенность берётся. Unit-тесты дают быструю обратную связь: запускаются мгновенно, падения легко локализуются, работают стабильно. Но они покрывают только локальную логику - изолированную от остальной системы. Их надёжность держится на том, что окружение подменено, а поведение - строго контролируется.

Интеграционные тесты покрывают больше связей. Они проверяют, как компоненты работают вместе: контроллер, сервис, база, кэш, очередь. Они ближе к реальности, но требуют больше инфраструктуры, сложнее в запуске, и их падения труднее отлаживать. Тем не менее, именно они ловят те ошибки, которые unit-прогон никогда не заметит.

Это всегда компромисс. Между скоростью и полнотой. Между удобством и точностью. Между временем отклика и глубиной сигнала. И пока эти компромиссы не осознаны - ни одно число покрытия не имеет смысла. «100% покрытие» может означать как надёжную защиту, так и пустой ритуал - всё зависит от того, что именно вы покрыли, и зачем.

Что мы действительно тестируем

На практике, когда мы пишем тест, мы не «проверяем весь модуль» - мы проверяем конкретное поведение в конкретной ситуации. Например: что при определённых входных данных метод вызывает другой компонент с нужным параметром. Или что система возвращает ожидаемый результат. Или что в случае ошибки происходит fallback, а не крэш. Или что состояние сохраняется при переходе экрана.

Это не «абсолютная проверка системы». Это фрагмент поведения, проверенный в специально собранном окружении - со стабами, фейками и полной симуляцией контекста. Мы руками собираем эту модель и утверждаем: вот в таких условиях система должна вести себя так-то.

Чем ближе этот контекст к продакшену - тем ценнее тест. Чем дальше - тем он быстрее, дешевле и проще, но локальнее по смыслу. И в этом нет ничего плохого. Главное - понимать, что именно мы тестируем, и почему.

Тест - это не способ доказать, что всё работает. Это способ убедиться, что в заданных условиях ничего не сломано. И если условия выбраны правильно - этого уже достаточно, чтобы тест выполнял свою работу честно.

4. Expect / Actual: Слова, которые всё говорят

Почти в каждом тесте есть одна и та же сцена: мы сравниваем то, что получилось, с тем, что мы ожидали. Чаще всего - через assertEquals(expected, actual), assertTrue(condition), assertFailsWith<SomeException>() или их аналоги. И вроде бы всё очевидно: проверяем, что результат совпадает с ожиданием. Но даже в этих простых вызовах есть неочевидный момент.

Не просто сравнение, а утверждение

Когда мы вызываем assertEquals(expected, actual), мы не просто сверяем два значения. Мы делаем утверждение. Мы говорим: если actual не совпадает с expected - значит, нарушен контракт. Это не операция сравнения, это тест на согласие с нашей моделью.

Важно понимать: assert* - это не отладка. Не исследование. Это декларация. Тест либо пройден, либо нет. Нет «почти», нет «предупреждения». И если условие не выполняется - не система сломалась, а наши ожидания не совпали с реальностью.

Expected/Actual - порядок, который важен

Почти все assert-функции следуют одному и тому же порядку: сначала expected, потом actual. И это не случайность. Это отражение позиции: «мы считаем, что вот это - правильно, а теперь проверим, совпадает ли с тем, что вышло». В этом смысле actual - первичен. Он результат. Он - реальность. А expected - это наша гипотеза.

Если местами их перепутать, тест всё равно будет работать. Но смысл высказывания становится неявным. Падает читаемость. И, главное, исчезает ощущение, что именно пошло не так: то ли мы ошиблись в модели, то ли код нарушил контракт.

assertTrue / assertFalse - примитив, но с той же логикой

Функции assertTrue и assertFalse не используют expected/actual явно, но логика та же: вы передаёте условие, которое считаете * *допустимым**. Если оно ложно - тест не пройден. Значит, что-то пошло не так в коде, в контракте или в наших представлениях о корректности.

По сути, каждая assert* - это бинарный фильтр: либо да, либо нет. Это инструмент для фиксации допусков, а не для поиска ошибок. И чем яснее мы это осознаём - тем точнее пишем тесты.

Ключевые слова Kotlin Native? Не об этом речь

Да, в Kotlin Native действительно есть ключевые слова expect и actual. Они используются в механизме мультиплатформенности: expect задаёт интерфейс, actual - реализацию для конкретной платформы. Это может сбить с толку - особенно начинающих. Но в контексте тестирования мы говорим совсем о другом. Здесь expected и actual - это соглашение на уровне мышления, а не синтаксиса. Это структура суждения: “я ожидаю X, и вижу Y - совпали ли они?”

Если воспринимать тест как акт сверки - expected и actual не более чем параметры. Но если понимать, что тест - это артефакт соглашения, то expected - это наш манифест. А actual - это отражение реальности. И когда они не совпадают, вопрос не в ошибке, а в расхождении между тем, что система делает, и тем, что мы от неё хотели.

Писать тест - значит формализовать ожидания. А assertEquals(expected, actual) - это не просто вызов функции. Это фраза. Она что-то утверждает. И если её читать именно как утверждение - структура начинает играть значение.

5. Природа Test Doubles: Mocks, Stubs, Fakes, Spies, Dummy

Описание фото

Когда мы говорим о Test Double, мы имеем в виду объекты, которые заменяют настоящие зависимости в тесте. Они позволяют запускать код в изоляции, без доступа к базе, сети, времени или другим внешним эффектам. Это делает тесты контролируемыми. Но важно понимать, что Double - это не просто «заглушка». Это модель. И выбор модели влияет на то, что именно мы тестируем.

Есть четыре основных типа Test Double, и у каждого - своя цель.

Stub - самый простой тестовый двойник. Он всегда возвращает заранее заданные значения и никак не реагирует на входные данные. Stub не проверяет, что ему передали, не логирует вызовы и не участвует в бизнес-логике - он просто стабилизирует поведение зависимости. Используется, когда зависимость нужна «для галочки»: достаточно вернуть нужное значение, чтобы тест мог продолжиться. Stub - это фон, а не часть сцены. Например, в тесте сервиса пользователей можно создать заглушку репозитория, которая всегда возвращает одного и того же пользователя, игнорируя запрошенный ID:

interface UserRepository {
    fun findById(id: String): User
}

class UserRepositoryStub : UserRepository {
    override fun findById(id: String): User {
        // Намеренно игнорируем id - Stub всегда возвращает одно и то же
        return User(id = "stub-id", name = "John Doe")
    }
}

// Использование в тесте
val stubRepo = UserRepositoryStub()
val service = UserService(stubRepo)
val user = service.getUser("123")
check(user.name == "John Doe")

В примере выше поведение Stub проявляется в том, что UserRepositoryStub всегда возвращает один и тот же результат (John Doe), независимо от входного параметра id. Он не анализирует, что ему передали, и служит лишь для стабилизации окружения.

Mock - объект, который фиксирует, как с ним взаимодействовали. Он запоминает, какие методы вызвали, с какими параметрами и сколько раз. Задача mock-объекта - не возвращать данные, а подтвердить, что код выполнил определённые действия. Mock используют там, где важно проверить, как происходило взаимодействие: был ли вызван логгер, ушло ли письмо, вызвался ли callback. Это объект-наблюдатель, с помощью которого тест проверяет поведение. Например, можно создать фальшивый логгер, который будет сохранять записанные сообщения вместо реального вывода, а затем убедиться в тесте, что нужный метод вызывался с ожидаемыми параметрами:

interface Logger {
    fun log(message: String)
}

class LoggerMock : Logger {
    val receivedMessages = mutableListOf<String>()

    override fun log(message: String) {
        receivedMessages.add(message)
    }
}

// Использование
val logger = LoggerMock()
val service = UserService(logger = logger)
service.createUser("Alice")
check(logger.receivedMessages.size == 1)
check(logger.receivedMessages.first() == "User created")

В примере выше поведение Mock проявляется в том, что LoggerMock запоминает все вызовы метода log. В тесте мы проверяем, что сообщение действительно было зафиксировано. Это и есть суть mock - не поведение, а проверка взаимодействия.

Mock часто реализуют с помощью библиотек вроде Mockito или MockK, но здесь он показан вручную, чтобы разобрать суть концепции.

Fake - рабочая подделка. Это не имитация отдельных вызовов, а полноценная, но упрощённая реализация зависимости. Например, in-memory база данных или фейковый API, который хранит данные в памяти. Fake не просто «притворяется» - он действительно ведёт себя как настоящая система, только без использования реальных ресурсов. Его используют, когда нужна реальная логика взаимодействия, но не нужна тяжелая инфраструктура. Например, вместо обращения к настоящей базе данных можно использовать фейковый репозиторий, который хранит пользователей в памяти:

interface UserRepository {
    fun save(user: User)
    fun findById(id: String): User?
}

class FakeUserRepository : UserRepository {
    private val users = mutableMapOf<String, User>()

    override fun save(user: User) {
        users[user.id] = user
    }

    override fun findById(id: String): User? {
        return users[id]
    }
}

// Использование
val repo = FakeUserRepository()
repo.save(User("1", "Alice"))
val result = repo.findById("1")
check(result?.name == "Alice")

В примере выше поведение Fake проявляется в том, что FakeUserRepository действительно сохраняет и возвращает данные, как настоящий репозиторий, но без настоящей базы данных. Это полноценная логика - только in-memory.

Spy - «двойной агент». Он оборачивает реальный объект, но отслеживает, что с ним происходило. Spy не подменяет поведение (как mock) и не заменяет всю реализацию (как fake). Вместо этого он делегирует вызовы настоящему объекту и параллельно фиксирует обращения (например, считает вызовы или сохраняет параметры). Это компромисс между прозрачным поведением и возможностью анализировать взаимодействие. Например, можно обернуть реальный (или фейковый) репозиторий в класс-Spy, который будет делегировать вызовы базовому объекту и считать, сколько раз вызывался метод поиска пользователя:

class UserRepositorySpy(private val realRepo: UserRepository) : UserRepository {
    var findByIdCallCount = 0
    val capturedIds = mutableListOf<String>()

    override fun save(user: User) {
        realRepo.save(user)
    }

    override fun findById(id: String): User? {
        findByIdCallCount++
        capturedIds.add(id)
        return realRepo.findById(id)
    }
}

// Использование
val realRepo = FakeUserRepository()
realRepo.save(User("1", "Bob"))
val spy = UserRepositorySpy(realRepo)
val user = spy.findById("1")
check(user?.name == "Bob")
check(spy.findByIdCallCount == 1)
check(spy.capturedIds.contains("1"))

В примере выше поведение Spy проявляется в том, что UserRepositorySpy делегирует вызовы realRepo, но при этом фиксирует: сколько раз вызывался метод findById, какие значения передавались.

Spy полезен там, где поведение важно сохранить, но при этом нужно наблюдать за взаимодействиями. Мы видим как “что вызвали”, так и “что реально произошло”.

Dummy - «молчаливый статист». Он существует только потому, что метод требует аргумента, но сам по себе в тесте не используется. Dummy не выполняет действий, не хранит состояния и не участвует в логике - его задача одна: быть на месте, где требуется объект. Это самый простой и безопасный тип Test Double, позволяющий явно показать: «этот параметр здесь не важен».

Например, если метод требует Logger, но логирование в тесте не играет роли, можно передать dummy-реализацию:

class DummyLogger : Logger {
    override fun info(message: String) = Unit
    override fun error(message: String, throwable: Throwable?) = Unit
}

// Использование
val logger = DummyLogger()
val service = UserService(logger) // здесь логер не используется
val result = service.doSomething()
check(result == ExpectedResult)

В примере выше DummyLogger никак не влияет на результат doSomething(). Он передаётся только потому, что UserService требует Logger в конструкторе.

Dummy полезен там, где важна только сигнатура, а не смысл. Он делает зависимости явными, но без побочных эффектов или логики. Это форма честной заглушки, мы прямо говорим: «этот объект не играет роли, просто позвольте тесту выполниться».

Иногда выбор типа Double кажется техническим: что проще заиспользовать, что быстрее написать. Но на самом деле он отражает подход к архитектуре. Если мы используем stub - мы говорим: эта зависимость не важна. Если fake - мы признаём, что логика важна, но инфраструктура - нет. Если mock - мы хотим проконтролировать, как именно система взаимодействует. Spy - мы хотим знать детали, но не мешать процессу.

Это выбор: моделировать поведение или структуру. Проверять результат или отслеживать путь. Упростить окружение или сохранить его поведение. И когда Double выбран неправильно - тест может быть зелёным, но бессмысленным.

Поэтому вопрос не в том, какой Double использовать, а в том, что именно мы хотим зафиксировать в этом тесте.

6. Unit vs JUnit: Что действительно стоит за словами

Слово unit в программировании означает единицу поведения минимальный фрагмент системы, который можно протестировать изолированно. Это может быть функция, метод, модуль или класс. Главное он должен быть самодостаточным: то есть его поведение можно проверить без запуска всей системы.

Тест такого фрагмента называют unit-тестом он фокусируется не на всей программе, а на её наименьшей значимой части, изолируя внешние зависимости (через stub, mock, fake и т. д.).

Unit = Единица (поведения) , Unit Test = Проверка этой единицы , JUnit = Java Unit Testing Framework

JUnit это инструмент, изначально созданный для того, чтобы писать и запускать unit-тесты на Java. Название это сокращение от Java + Unit. Но за 20 лет JUnit стал не просто библиотекой, а полноценной тестовой платформой, которую можно использовать для чего угодно: от простейших проверок до интеграционных и property-based тестов.

Чтобы понимать, как писать хорошие тесты, важно разобраться не только в коде, но и в истории: как JUnit развивался, и какие идеи он принёс.

История JUnit: от процедур до DSL

JUnit 3 появился в начале 2000-х. Это был процедурный фреймворк, построенный на соглашениях, а не на аннотациях или конфигурациях. Чтобы метод считался тестом, он должен был начинаться с test, а класс расширять TestCase. Всё наследовалось напрямую, никакой инверсии, никакой метаинформации.

public class UserServiceTest extends TestCase {
    public void testUserIsCreated() {
        UserService service = new UserService();
        User user = service.create("Alice");
        assertEquals("Alice", user.getName());
    }
}

Никакой магии. JUnit по имени искал методы, начинал их выполнять и сигнализировал о падениях через AssertionFailedError или исключения. Всё держалось на соглашениях и строгом порядке.

JUnit 4 стал поворотной точкой. Он избавился от необходимости наследования TestCase и перешёл к декларативному стилю на базе аннотаций. Метод помечался @Test, и это было достаточно, чтобы фреймворк понял перед ним тест.

class UserServiceTest {

    @Test
    fun userIsCreated() {
        val service = UserService()
        val user = service.create("Alice")
        assertEquals("Alice", user.name)
    }
}

Появились @Before, @After, @Ignore, а также возможность писать кастомные раннеры. Но при всём этом JUnit 4 оставался процедурным. Каждый тест по сути обычная функция, просто обёрнутая инфраструктурой.

JUnit 5 уже не фреймворк, а платформа. Он разделён на три части: Platform, Jupiter и Vintage.

JUnit Platform это базовый механизм обнаружения и запуска тестов. Он взаимодействует с Gradle, Maven, IDE и CI-системами. Через него тесты регистрируются, исполняются и репортуются. Это слой интеграции, а не логики. JUnit Jupiter это современный API и тестовый движок. Он поддерживает всё, что появилось в JUnit 5: вложенные тестовые классы ( @Nested), настраиваемые названия (@DisplayName), параметризованные и динамические тесты (@ParameterizedTest, @TestFactory). Jupiter делает тесты декларативными и выразительными, приближая их к DSL. JUnit Vintage это адаптер. Он позволяет запускать старые тесты, написанные на JUnit 3 и 4, внутри новой платформы. Благодаря Vintage проекты могут мигрировать постепенно, без полного переписывания.

Синтаксис стал выразительным. Теперь тесты можно строить как декларации поведения, использовать параметры, вводить структуры.

@ParameterizedTest
@ValueSource(strings = ["admin", "user", "guest"])
fun `roles should not be empty`(role: String) {
    val system = RoleService()
    val permissions = system.getPermissions(role)
    assertTrue(permissions.isNotEmpty())
}

JUnit 5 перестал быть “юнит”-фреймворком в узком смысле. Он стал каркасом для любых тестов: юнитов, интеграций, property-based подхода, контрактных проверок. Всё, что можно выразить в виде исполняемого DSL с проверками теперь помещается внутрь.

JUnit не только принял декларативность, но и сам стал частью архитектурного мышления. И теперь, говоря @Test, мы запускаем не просто метод мы инициируем часть тестового пайплайна, где поведение фиксируется как артефакт.

Почему это важно в CI

JUnit-интеграции в IDE, Gradle, Maven, Bazel и CI-системы (например, GitHub Actions) ожидают строго определённую структуру: @Test-методы автоматически индексируются, изолируются и репортятся. Без этой аннотации метод просто функция, недоступная для раннера.

Знакомство с основами JUnit 5

Описание фото

Если в JUnit 4 аннотации были просто флажками, то в JUnit 5 они стали формализованными элементами контракта. Через них описывается жизненный цикл, поведение, параметры и точки интеграции тестов. Ниже минимально необходимый набор аннотаций, чтобы уверенно ориентироваться в Jupiter.

@Test Это главная точка входа. Без неё метод просто функция. С ней полноценный тест, который JUnit включит в жизненный цикл: вызовет @BeforeEach, сам тест, @AfterEach, соберёт результат и отобразит в отчёте.

Метод с @Test должен быть открытым (public), без параметров, без возвращаемого значения (Unit) и не static (в Java) или companion (в Kotlin). Если тест бросает исключение он считается проваленным. Если нет прошёл успешно.

@Test
fun `user is created`() {
    val service = UserService()
    val user = service.create("Alice")
    assertEquals("Alice", user.name)
}

JUnit вызывает этот метод как автономную единицу отдельно от других, в новом инстансе класса (если не указано иначе). Это часть контракта: тест не должен зависеть от других тестов и делиться с ними состоянием.

@BeforeEach / @AfterEach Вызываются перед и после каждого @Test. Используются для настройки окружения и его очистки: создание файлов, сброс состояний, закрытие ресурсов. Каждый тест запускается на новом экземпляре класса, так что состояния между методами не сохраняются.

@BeforeEach
fun setUp() {
    initDatabase()
}

@AfterEach
fun tearDown() {
    cleanupTempFiles()
}

@BeforeAll / @AfterAll Запускаются один раз до и после всех тестов в классе. Используются для тяжёлой инициализации (например, поднятие embedded Redis, Kafka, Docker-контейнеров). В Kotlin требуют @TestInstance(PER_CLASS).

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@BeforeAll
fun initSuite() {
    EmbeddedRedis.start()
}

@TestInstance(...) Определяет, как JUnit создаёт экземпляры тестового класса.

По умолчанию используется PER_METHOD, при котором для каждого метода с @Test создаётся отдельный объект. Это обеспечивает изоляцию между тестами, но не позволяет сохранять общее состояние между ними.

Если указать PER_CLASS, то один объект создаётся на весь класс. Это позволяет использовать @BeforeAll и @AfterAll как обычные методы (не в companion object), а также делиться состоянием между тестами.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ConfiguredTestLifecycle {

    private var counter = 0

    @BeforeAll
    fun initOnce() {
        counter = 10
    }

    @Test
    fun testA() {
        assert(counter >= 10)
    }

    @Test
    fun testB() {
        counter++ // состояние сохраняется между тестами
    }
}

Такой подход используют, когда инициализация тяжёлая или состояние нужно сохранить между тестами. Но в большинстве случаев PER_METHOD безопаснее.

@DisplayName Позволяет задать читаемое название теста. Отображается в IDE и CI, особенно полезно в параметризованных или BDD-ориентированных тестах. Работает и на методах, и на классах.

@DisplayName("Пользователь с ролью admin получает все доступы")
@Test
fun adminGetsAllPermissions() {
    ...
}

@Disabled Пропускает тест. Используется для временного отключения (например, нестабильный код, баг в зависимости). В отчётах видно, что тест отключён.

@Disabled("Отключён до фикса external API")
@Test
fun flakyIntegrationTest() {
    ...
}

@Nested Позволяет структурировать тесты в иерархии с вложенными контекстами. Применяется только к inner class, иначе инстанс не создаётся. Удобен для Given/When/Then-структур.

@Nested
inner class WhenUserIsGuest {
    @Test
    fun `should not access admin panel`() {
        ...
    }
}

@ParameterizedTest Запускает один и тот же метод несколько раз с разными параметрами. Требует указания источника данных через @ValueSource, @CsvSource, @EnumSource и др. Метод должен принимать аргументы.

@ParameterizedTest
@ValueSource(strings = ["admin", "user", "guest"])
fun testRoles(role: String) {
    assertTrue(role.isNotBlank())
}

@ValueSource, @CsvSource, @EnumSource, @MethodSource, @ArgumentsSource Аннотации для генерации данных в параметризованных тестах. @ValueSource для простых значений. @CsvSource для табличных данных. @MethodSource ссылка на метод, возвращающий Stream<Arguments>. @EnumSource прокидывает значения enum. @ArgumentsSource настраиваемый источник данных.

@ParameterizedTest
@CsvSource("admin, true", "guest, false")
fun rolePermissionTest(role: String, allowed: Boolean) {
    assertEquals(allowed, checkAccess(role))
}

@RepeatedTest Запускает один и тот же тест несколько раз. Удобен для проверки нестабильных сценариев: флаки, гонки, недетерминированные вычисления. Метод может принимать RepetitionInfo.

@RepeatedTest(3)
fun unstableTest() {
    assertTrue(runComputation().isSuccessful)
}

@Timeout Завершает тест с ошибкой, если он выполняется дольше заданного времени. Применяется на метод и на весь класс. Полезен для защиты от зависаний.

@Timeout(5)
@Test
fun longRunningProcessCompletes() {
    performHeavyOperation()
}

@Tag Добавляет произвольные метки тестам. Используются в CI/CD для фильтрации: можно запускать только тесты с нужным тегом (@Tag("slow"), @Tag("ci")).

@Tag("integration")
@Test
fun savesToDatabase() {
    saveToDb()
}

@ExtendWith(...) Подключает расширения (Mockito, Spring, Testcontainers, кастомные хуки). Extension-интерфейсы могут внедрять зависимости, слушать события, управлять окружением.

@ExtendWith(SpringExtension::class)
class UserServiceSpringTest { ... }

@TestFactory Позволяет генерировать тесты динамически во время выполнения. Метод должен возвращать Collection<DynamicTest> или Stream<DynamicTest>. Применяется при неизвестном числе кейсов (например, из JSON-файла).

@TestFactory
fun dynamicTestsFromFile(): List<DynamicTest> {
    return File("cases.txt").readLines().map { line ->
        dynamicTest("Case: $line") {
            check(line.isNotBlank())
        }
    }
}

В статье не будет практического примера по использованию аннотаций для этого существует официальная документация: Junit 5 Documentation, которая очень подробно описывает возможности и использование JUnit 5.

Как JUnit 5 запускает ваш @Test: от команды до метода

Когда вы нажимаете “Run” в IDE или запускаете ./gradlew test, за этим простым действием скрывается сложная многоуровневая архитектура. Давайте проследим весь путь выполнения теста от команды в терминале до вызова вашего метода, помеченного @Test.

Рабочий пример для демонстрации

Возьмем что-то практичное загрузчик изображений. Код простой, но вполне рабочий:

class ImageDownloader {
    fun downloadImage(url: String): ByteArray {
        val inputStream = URI(url).toURL().openStream()
        return inputStream.use { it.readAllBytes() }
    }
}

Далее тест для ImageDownloader с использованием JUnit 5:

@TestInstance(TestInstance.Lifecycle.PER_METHOD)
@DisplayName("Загрузка изображения и сохранение в файл")
class ImageDownloaderTest {

    private lateinit var tempFile: File
    private lateinit var outputStream: FileOutputStream

    private val imageUrl = "https://i.postimg.cc/26h8JBpH/QYbec-Thl-Qy7mcx-ZBYp-C0m-BDp16no-Mt-R5vwe-St-Wmv-large.jpg"

    @BeforeEach
    fun setUp() {
        tempFile = File("image-test.jpg")
        outputStream = FileOutputStream(tempFile, true)
    }

    @Test
    @DisplayName("Изображение должно быть скачано и записано в файл")
    fun `downloaded image is saved to file`() {
        val downloader = ImageDownloader()
        val bytes = downloader.downloadImage(imageUrl)

        requireNotNull(bytes) { "Скачанные данные не должны быть null" }

        outputStream.write(bytes)
        outputStream.flush()

        assertTrue(tempFile.length() > 0, "Файл после загрузки не должен быть пустым")
    }

    @AfterEach
    fun tearDown() {
        outputStream.close()
        tempFile.delete()
    }
}

Тест довольно straightforward: есть один метод downloaded image is saved to file, который скачивает изображение и проверяет, что файл действительно создался и не пустой. В setUp готовим временный файл и поток, в tearDown всё убираем за собой. @DisplayName нужен для читаемых названий в отчетах вместо технических имен методов увидим осмысленные описания.

Screenshot Screenshot

Теперь запустим тест через Gradle:

./gradlew :test --tests "test.ImageDownloaderTest"

Эта команда сработает, если в build.gradle настроена задача test:

tasks.test {
    useJUnitPlatform()
}

И вот здесь начинается интересное за кулисами запускается целая цепочка вызовов, которая проходит через несколько архитектурных слоев.

Этап 1: Gradle настраивает JUnit Platform

Вызов useJUnitPlatform() в Gradle это не просто конфигурационная строчка. Под капотом происходит следующее:

public void useJUnitPlatform() {
    useTestFramework(new JUnitPlatformTestFramework((DefaultTestFilter) getFilter(), true, getDryRun()));
}

Gradle создает экземпляр JUnitPlatformTestFramework, который реализует интерфейс TestFramework. Этот объект станет мостом между Gradle и JUnit Platform он знает, как найти тесты, как их запустить и как получить результаты.

Интересный факт: несмотря на то, что Kotlin набирает популярность, и наш тест написан на нем, исходники JUnit по-прежнему остаются верными букве “J” в названии, то есть на Java. Аннотация @Test выглядит так:


@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = STABLE, since = "5.0")
@Testable
public @interface Test {
}

Документация к аннотации гласит: “@Test используется для обозначения тестового метода. Методы с @Test не должны быть private или static и не должны возвращать значение”. Простые правила, но за ними стоит продуманная архитектура.

Этап 2: Создание фабрики процессоров тестов

Когда Gradle готов запускать тесты, он обращается к нашему JUnitPlatformTestFramework за фабрикой процессоров:

public class JUnitPlatformTestFramework implements TestFramework {
    @Override
    public WorkerTestClassProcessorFactory getProcessorFactory() {
        return new JUnitPlatformTestClassProcessorFactory(new JUnitPlatformSpec(...));
    }
}

Эта фабрика умеет создавать процессоры тестовых классов объекты, которые знают, как обрабатывать отдельные тестовые классы. Когда приходит время, фабрика создает JUnitPlatformTestClassProcessor:

public class JUnitPlatformTestClassProcessorFactory implements WorkerTestClassProcessorFactory {
    @Override
    public WorkerTestClassProcessor create(...,JUnitPlatformSpec spec) {
        return new JUnitPlatformTestClassProcessor(spec, ...);
    }
}

JUnitPlatformTestClassProcessor наследуется от AbstractJUnitTestClassProcessor и реализует интерфейс TestClassProcessor. Это ключевой компонент именно он будет координировать выполнение наших тестов.

Этап 3: TestWorker начинает обработку

Внутри Gradle работает TestWorker компонент, который управляет жизненным циклом выполнения тестов.

package org.gradle.api.internal.tasks.testing.worker;

public class TestWorker implements Action<WorkerProcessContext>, RemoteTestClassProcessor, Serializable, Stoppable {

    private TestClassProcessor processor;

    @Override
    public void processTestClass(final TestClassRunInfo testClass) {
        ...
        processor.processTestClass(testClass);
        ...
    }

    @Override
    public void startProcessing() {
        ...
        processor.startProcessing(resultProcessor);
    }
}

TestWorker вызывает два ключевых метода процессора:

public abstract class AbstractJUnitTestClassProcessor implements TestClassProcessor {

    private Action<String> executor;

    @Override
    public void startProcessing(TestResultProcessor resultProcessor) {
        TestResultProcessor resultProcessorChain = createResultProcessorChain(resultProcessor);
        resultProcessorActor = actorFactory.createBlockingActor(resultProcessorChain);
        executor = createTestExecutor(resultProcessorActor);
    }

    @Override
    public void processTestClass(TestClassRunInfo testClass) {
        LOGGER.debug("Executing test class {}", testClass.getTestClassName());
        executor.execute(testClass.getTestClassName());
    }
}

На этапе startProcessing создается цепочка обработчиков результатов и actor для потокобезопасной работы с ними. Actor это паттерн, который гарантирует, что все операции с результатами тестов будут выполняться последовательно, даже если тесты запускаются в нескольких потоках.

Метод processTestClass получает информацию о тестовом классе и передает его имя executor’у для выполнения. Но тут есть подвох тесты еще не запускаются!

Этап 4: Накопление классов перед запуском

В JUnitPlatformTestClassProcessor используется интересная стратегия сначала собрать все тестовые классы, а потом запустить их разом:

public class JUnitPlatformTestClassProcessor extends AbstractJUnitTestClassProcessor {

    @Override
    protected Action<String> createTestExecutor(Actor resultProcessorActor) {
        TestResultProcessor threadSafeResultProcessor = resultProcessorActor.getProxy(TestResultProcessor.class);
        launcherSession = BackwardsCompatibleLauncherSession.open();
        junitClassLoader = Thread.currentThread().getContextClassLoader();
        testClassExecutor = new CollectAllTestClassesExecutor(threadSafeResultProcessor);
        return testClassExecutor;
    }

    @Override
    public void stop() {
        testClassExecutor.processAllTestClasses();
        launcherSession.close();
        super.stop();
    }
}

CollectAllTestClassesExecutor это внутренний класс, который просто накапливает имена тестовых классов:

private class CollectAllTestClassesExecutor implements Action<String> {
    private final List<Class<?>> testClasses = new ArrayList<>();

    @Override
    public void execute(@Nonnull String testClassName) {
        Class<?> klass = loadClass(testClassName);
        testClasses.add(klass);
    }

    void processAllTestClasses() {
        LauncherDiscoveryRequest discoveryRequest = createLauncherDiscoveryRequest(testClasses);
        TestExecutionListener executionListener = new JUnitPlatformTestExecutionListener(...);
        Launcher launcher = launcherSession.getLauncher();
        launcher.execute(discoveryRequest, executionListener);
    }
}

Такой подход позволяет JUnit Platform получить полную картину всех тестов перед началом выполнения. Это важно для планирования выполнения, распределения по потокам и создания правильной структуры отчетов.

Реальный запуск происходит только когда Gradle вызывает stop() на процессоре, что приводит к вызову processAllTestClasses().

Этап 5: Launcher берет управление на себя

Когда все классы собраны, создается LauncherDiscoveryRequest объект, который описывает, какие тесты нужно найти и выполнить. Затем получается экземпляр Launcher из сессии и запускается выполнение:

Launcher launcher = launcherSession.getLauncher();
launcher.

execute(discoveryRequest, executionListener);

Launcher это центральная точка входа в JUnit Platform. Его реализация DefaultLauncher выглядит довольно просто:

public class DefaultLauncher implements Launcher {

    private final EngineExecutionOrchestrator executionOrchestrator = new EngineExecutionOrchestrator(...);

    @Override
    public void execute(TestPlan testPlan, TestExecutionListener... listeners) {
        execute((InternalTestPlan) testPlan, listeners);
    }

    private void execute(InternalTestPlan internalTestPlan, TestExecutionListener[] listeners) {
        executionOrchestrator.execute(internalTestPlan, listeners);
    }
}

Основная работа делегируется в EngineExecutionOrchestrator оркестратор выполнения движков тестов.

Этап 6: Оркестратор запускает движки тестов

JUnit Platform построена по модульному принципу разные типы тестов могут выполняться разными движками (engines). Для JUnit 5 это JupiterTestEngine, для JUnit 4 VintageTestEngine, есть движки для TestNG и других фреймворков.

public class EngineExecutionOrchestrator {

    public void execute(LauncherDiscoveryResult discoveryResult, EngineExecutionListener engineExecutionListener) {
        for (TestEngine testEngine : discoveryResult.getTestEngines()) {
            TestDescriptor engineDescriptor = discoveryResult.getEngineTestDescriptor(testEngine);
            testEngine.execute(new ExecutionRequest(engineDescriptor, delayingListener, configurationParameters));
        }
    }
}

Для каждого найденного движка создается ExecutionRequest с описанием тестов, которые этот движок должен выполнить, и вызывается execute().

В нашем случае будет использоваться JupiterTestEngine движок для JUnit Jupiter (официальное название JUnit 5).

Этап 7: JupiterTestEngine организует иерархическое выполнение

JupiterTestEngine наследуется от HierarchicalTestEngine базового класса для движков, которые работают с иерархической структурой тестов:

public final class JupiterTestEngine extends HierarchicalTestEngine<JupiterEngineExecutionContext> {

    @Override
    public void execute(ExecutionRequest request) {
        try (HierarchicalTestExecutorService executorService = createExecutorService(request)) {
            JupiterEngineExecutionContext executionContext = createExecutionContext(request);
            ThrowableCollector.Factory throwableCollectorFactory = createThrowableCollectorFactory(request);

            new HierarchicalTestExecutor<>(
                    request,
                    executionContext,
                    executorService,
                    throwableCollectorFactory
            ).execute().get();
        } catch (Exception exception) {
            throw new JUnitException("Error executing tests for engine " + getId(), exception);
        }
    }
}

Здесь создается HierarchicalTestExecutor исполнитель, который умеет работать с деревом TestDescriptor’ов. Каждый TestDescriptor представляет узел в иерархии тестов это может быть движок, пакет, класс, метод или отдельный тестовый случай.

ExecutorService определяет, как будут выполняться тесты последовательно в одном потоке или параллельно в нескольких.ThrowableCollector нужен для корректной обработки исключений на разных уровнях иерархии.

Этап 8: HierarchicalTestExecutor запускает корневую задачу

class HierarchicalTestExecutor<C extends EngineExecutionContext> {

    Future<Void> execute() {
        NodeTestTask<C> rootTestTask = new NodeTestTask<>(taskContext, rootTestDescriptor);
        rootTestTask.setParentContext(this.rootContext);
        return this.executorService.submit(rootTestTask);
    }
}

Создается корневая задача NodeTestTask для корневого TestDescriptor и отправляется на выполнение в ExecutorService. Часто используется SameThreadHierarchicalTestExecutorService, который выполняет задачи синхронно:

public class SameThreadHierarchicalTestExecutorService implements HierarchicalTestExecutorService {

    @Override
    public Future<Void> submit(TestTask testTask) {
        testTask.execute();
        return CompletableFuture.completedFuture(null);
    }
}

В нашем примере используется SameThreadHierarchicalTestExecutorService исполнитель, который выполняет все NodeTestTask последовательно в одном потоке. Это поведение можно наблюдать при запуске одиночного тестового класса, особенно из IDE или через --tests в Gradle. Но важно понимать: выбор конкретной реализации HierarchicalTestExecutorService делает не разработчик, а сама JUnit Platform, опираясь на конфигурацию и масштаб тестового плана. Если платформа обнаруживает, что тестов много, или включена параллелизация ( junit.jupiter.execution.parallel.enabled=true), или запуск идёт в рамках всего проекта, она может подставить ForkJoinPoolHierarchicalTestExecutorService. В этом случае NodeTestTask’и распараллеливаются с помощью ForkJoinPool, что ускоряет прогон, но требует особого внимания к потокобезопасности и корректному управлению состоянием в @BeforeEach, @AfterEach и других фазах жизненного цикла. Фактически мы не выбираем, в каком потоке будет выполняться тест это делает движок, ориентируясь на тестовый план и окружение. Поэтому нельзя полагаться на порядок или изоляцию, если вы не контролируете среду исполнения явно.

Этап 9: NodeTestTask выполняет рекурсивную обработку узлов

NodeTestTask это обёртка над логикой выполнения одного TestDescriptor в иерархии тестов. Каждый такой узел может представлять движок, контейнер, класс, метод или даже динамический тест. Выполнение узла организовано через метод executeRecursively():

public class NodeTestTask<C extends EngineExecutionContext> implements TestTask {

    @Override
    public void execute() {
        executeRecursively();
    }

    private void executeRecursively() {
        throwableCollector.execute(() -> {
            node.around(context, ctx -> {
                context = node.before(context);
                context = node.execute(context, dynamicTestExecutor);
                taskContext.getExecutorService().invokeAll(children);
                dynamicTestExecutor.awaitFinished();
                node.after(context);
            });
        });
    }
}

Метод executeRecursively() реализует канонический жизненный цикл выполнения теста:

  1. before - подготовка окружения, выполнение @BeforeAll и @BeforeEach, создание экземпляра тестового класса
  2. execute - непосредственное выполнение логики узла (например, вызов метода с @Test)
  3. children - рекурсивное выполнение всех дочерних узлов
  4. dynamic - ожидание завершения динамически зарегистрированных тестов (через @TestFactory)
  5. after - завершающие действия, выполнение @AfterEach и @AfterAll

Ключевая строка здесь context = node.execute(context, dynamicTestExecutor). Вызов делегируется текущему TestDescriptor, чья реализация определяет, что именно будет исполнено.

В случае обычного тестового метода этот узел будет представлять собой экземпляр TestMethodTestDescriptor, а значит, выполнение пойдёт в его метод execute():


@Override
public JupiterEngineExecutionContext execute(JupiterEngineExecutionContext context,
                                             DynamicTestExecutor dynamicTestExecutor) {
    ThrowableCollector throwableCollector = context.getThrowableCollector();

    invokeBeforeEachCallbacks(context);
    if (throwableCollector.isEmpty()) {
        invokeBeforeEachMethods(context);
        if (throwableCollector.isEmpty()) {
            invokeBeforeTestExecutionCallbacks(context);
            if (throwableCollector.isEmpty()) {
                invokeTestMethod(context, dynamicTestExecutor);
            }
            invokeAfterTestExecutionCallbacks(context);
        }
        invokeAfterEachMethods(context);
    }
    invokeAfterEachCallbacks(context);

    return context;
}

Таким образом, вызов node.execute(...) на самом деле инициирует выполнение целого сценария: от вызова @BeforeEach и @BeforeTestExecution, до реального @Test-метода (внутри invokeTestMethod(...)) и последующего завершения через @AfterEach.

Это значит, что TestMethodTestDescriptor внутри себя не просто вызывает Method.invoke(...), а тщательно оборачивает его в точках расширения, где могут подключиться extension’ы, interception’ы и пользовательская логика. Именно поэтому @Test не просто вызов метода, а управляемый, фазовый процесс с точками вмешательства на каждом этапе.

Этап 10: Выполнение конкретного тестового метода

Когда очередь доходит до узла типа MethodTestDescriptor (это наш метод downloaded image is saved to file), вызывается его метод execute():


@Override
public JupiterEngineExecutionContext execute(JupiterEngineExecutionContext context, DynamicTestExecutor dynamicTestExecutor) {
    invoker.invoke(context.getExtensionRegistry(), context.getTestInstance(), executable);
    return context;
}

Здесь invoker это экземпляр ExecutableInvoker, а executable объект типа Method, который ссылается на наш тестовый метод.

ExecutableInvoker обрабатывает параметры метода (если они есть), применяет расширения и в конце концов вызывает:

public class ExecutableInvoker {

    public Object invoke(ExtensionRegistry extensionRegistry, Object target, Executable executable, Object... arguments) throws Throwable {
        ...
        return ReflectionUtils.invokeMethod((Method) executable, target, arguments);
    }
}

А ReflectionUtils.invokeMethod() делает то, что и следует из названия:

public final class ReflectionUtils {

    public static Object invokeMethod(Method method, Object target, Object... arguments) throws Exception {
        method.setAccessible(true);
        return method.invoke(target, arguments);
    }
}

И вот здесь, наконец, происходит то, ради чего была запущена вся эта машинерия, выполняется Method.invoke() на экземпляре нашего класса ImageDownloaderTest, вызывая метод downloaded image is saved to file().

Этап 11: Обработка динамических тестов

JUnit 5 поддерживает динамические тесты тесты, которые создаются во время выполнения через @TestFactory. Если в нашем классе был бы такой метод:

@TestFactory
fun dynamicTests(): Stream<DynamicTest> {
    return Stream.of(
        DynamicTest.dynamicTest("Test 1") { /* логика теста 1 */ },
        DynamicTest.dynamicTest("Test 2") { /* логика теста 2 */ }
    )
}

То эти тесты регистрировались бы через DynamicTestExecutor:

public interface DynamicTestExecutor {
    void execute(TestDescriptor dynamicTestDescriptor);

    void awaitFinished();
}

Во время выполнения фабричного метода динамические тесты регистрируются через execute(), а их реальное выполнение происходит при вызове awaitFinished() в NodeTestTask. Это позволяет поддерживать правильный порядок выполнения и корректно обрабатывать результаты динамических тестов.

Как JUnit узнаёт, что перед ним тест?

В предыдущей главе мы остановились на моменте, когда @Test-метод уже исполняется. Но как JUnit вообще узнаёт, что этот метод - тест? И откуда он вообще берёт класс?

На самом деле всё начинается сильно раньше - ещё в момент, когда Gradle запускает worker-процесс, где впоследствии и будет обнаружен наш тестовый класс.

Разберёмся, как Gradle собирает окружение, и какие шаги ведут к запуску тестового Runnable из пользовательского TestWorker. Всё, что не критично - отправим в троеточие.

GradleWorkerMain - точка входа в дочерний worker-процесс Gradle

public class GradleWorkerMain {

    public void run() throws Exception {
        Class<? extends Callable<Void>> workerClass = (Class<? extends Callable<Void>>) implementationClassLoader.loadClass("org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker").asSubclass(Callable.class);
        Callable<Void> main = workerClass.getConstructor(DataInputStream.class).newInstance(instr);
        main.call();
    }

    public static void main(String[] args) {
        new GradleWorkerMain().run();
        System.exit(0);
    }
}

Здесь формируется worker-класс, через который всё и запускается. Пока всё похоже на обычный Java bootstrap - но дальше начинается специфичная инициализация Gradle. Комментарий: GradleWorkerMain - это основная точка входа для любого внешнего worker-процесса Gradle. Он изолирован от основного демона Gradle и запускается в отдельном процессе JVM. Его задача - создать нужный Callable, загрузив его через implementationClassLoader, и запустить его. Это инфраструктурный механизм Gradle для выполнения изолированных задач (компиляции, тестов, аннотации и т. п.).

SystemApplicationClassLoaderWorker - адаптер, запускающий рабочую логику в контексте Gradle Worker API

public class SystemApplicationClassLoaderWorker implements Callable<Void> {

    @Override
    public Void call() throws Exception {
        ...
        ActionExecutionWorker worker = new ActionExecutionWorker(config.getWorkerAction());
        worker.execute(new ContextImpl(...));
        ...
    }
}

Здесь мы впервые встречаем config.getWorkerAction() - именно он содержит TestWorker, который отвечает за запуск тестов. Но пока он обёрнут в универсальный ActionExecutionWorker. Комментарий: SystemApplicationClassLoaderWorker - это внутренний запускной адаптер Gradle, который оборачивает и исполняет реальную рабочую нагрузку. Он используется для переключения контекста ClassLoader-а (SystemApplicationClassLoader) и передачи управления ActionExecutionWorker. Именно в этом классе происходит развертывание конфигурации worker-а (config) и передача управления реальной задаче.

ActionExecutionWorker - вызов настоящего TestWorker

public class ActionExecutionWorker implements Action<WorkerProcessContext> {
    private final Action<? super WorkerProcessContext> action;

    public ActionExecutionWorker(Action<? super WorkerProcessContext> action) {
        this.action = action;
    }

    @Override
    public void execute(final WorkerProcessContext workerContext) {
        ObjectConnection clientConnection = workerContext.getServerConnection();
        clientConnection.addUnrecoverableErrorHandler(new Action<Throwable>() {
            @Override
            public void execute(Throwable throwable) {
                if (action instanceof Stoppable) {
                    ((Stoppable) action).stop();
                }
            }
        });

        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        Thread.currentThread().setContextClassLoader(action.getClass().getClassLoader());
        try {
            action.execute(workerContext); // <-- ключевой момент: вызывает TestWorker
        } finally {
            ...
        }
    }
}

Здесь action - это и есть TestWorker, просто замаскированный под Action. Этот вызов приводит к запуску кода, который действительно управляет жизненным циклом тестов. Комментарий: ActionExecutionWorker - универсальный обёртчик, запускающий пользовательский Action (в нашем случае - TestWorker). Он также добавляет обработчик критических ошибок (addUnrecoverableErrorHandler), который при сбое выполнит stop() у TestWorker. Это инфраструктурный компонент Gradle Worker API, обеспечивающий безопасное выполнение задач.

TestWorker - управление жизненным циклом и запуск тестов

public class TestWorker implements Action<WorkerProcessContext>, RemoteTestClassProcessor, Serializable, Stoppable {

    @Override
    public void execute(final WorkerProcessContext workerProcessContext) {
        ...
        CloseableServiceRegistry testServices = TestFrameworkServiceRegistry.create(workerProcessContext);
        startReceivingTests(workerProcessContext, testServices);

        try {
            while (state != State.STOPPED) {
                executeAndMaintainThreadName(runQueue.take());
            }
        } finally {
            ...
            testServices.close();
        }
    }

    private static void executeAndMaintainThreadName(Runnable action) {
        try {
            action.run();
        } finally {
            Thread.currentThread().setName(WORK_THREAD_NAME);
        }
    }

    @Override
    public void stop() {
        submitToRun(new Runnable() {
            @Override
            public void run() {
                try {
                    processor.stop();
                } finally {
                    state = State.STOPPED;
                    // Clean the interrupted status
                    // because some test class processors do work here, e.g. JUnitPlatform
                    Thread.interrupted();
                }
            }
        });
    }

}

Здесь начинается реальная жизнь тестов. Метод startReceivingTests() инициирует приём тестов от Master-процесса, а runQueue.take() вытаскивает очередной Runnable - в том числе и JUnit-подобные вызовы. Но кто решает, какие классы положить в эту очередь? executeAndMaintainThreadName, вызывая action.run(), на самом деле вызовет stop у TestWorker. Комментарий: TestWorker - это главная точка управления тестами на стороне worker-процесса. Он слушает очередь задач (runQueue) и исполняет их. Именно сюда Master отправляет команды через RemoteTestClassProcessor → Dispatch → MethodInvocation. Также реализует Stoppable - чтобы корректно завершить тестовый процесс по команде снаружи (в том числе при отмене сборки).

Отлично, теперь мы перешли к следующему важному слою - механизму доставки и вызова тестов. На этом этапе TestWorker уже готов принимать команды, но кто же инициирует вызовы тестов и как классы с @Test действительно попадают в исполнение?

Разбираем цепочку, в которой Dispatch, MethodInvocation и TestClassProcessor играют ключевую роль в том, как тестовый класс сначала * *обнаруживается**, а затем **передаётся** для обработки.

В этом этапе мы находимся чуть выше JUnit Platform, на уровне Gradle Test Framework. Здесь через прокси и диспатчер происходит вызов методов, которые в итоге передают управление JUnit Engine’у.

ProxyDispatchAdapter - создание прокси, который делегирует вызовы через Dispatch

public class ProxyDispatchAdapter<T> {
    private final Class<T> type;
    private final T source;

    public ProxyDispatchAdapter(Dispatch<? super MethodInvocation> dispatch, Class<T> type, Class<?>... extraTypes) {
        this.type = type;
        List<Class<?>> types = new ArrayList<Class<?>>();
        ClassLoader classLoader = type.getClassLoader();
        types.add(type);
        for (Class<?> extraType : extraTypes) {
            ...
            types.add(extraType);
        }
        source = type.cast(Proxy.newProxyInstance(
                classLoader,
                types.toArray(new Class<?>[0]),
                new DispatchingInvocationHandler(type, dispatch)));
    }

    public T getSource() {
        return source;
    }

    private static class DispatchingInvocationHandler implements InvocationHandler {
        private final Class<?> type;
        private final Dispatch<? super MethodInvocation> dispatch;

        @Override
        public Object invoke(Object target, Method method, Object[] parameters) throws Throwable {
            dispatch.dispatch(new MethodInvocation(method, parameters));
            return null;
        }
    }
}

Здесь создаётся динамический прокси (через java.lang.reflect.Proxy), который вместо непосредственного вызова метода прокидывает его как MethodInvocation в Dispatch. Это ключевая прослойка для удалённого и deferred-вызова processTestClass(...). Комментарий: используется в master-процессе. Возвращает реализацию интерфейса TestClassProcessor, но на деле метод будет превращён в MethodInvocation и передан в Dispatch, который отправит его в дочерний процесс.

ContextClassLoaderDispatch - временно меняет classloader на тестовый

public class ContextClassLoaderDispatch<T> implements Dispatch<T> {
    private final Dispatch<? super T> dispatch;
    private final ClassLoader contextClassLoader;

    @Override
    public void dispatch(T message) {
        ClassLoader original = Thread.currentThread().getContextClassLoader();
        Thread.currentThread().setContextClassLoader(contextClassLoader);
        try {
            dispatch.dispatch(message);
        } finally {
            Thread.currentThread().setContextClassLoader(original);
        }
    }
}

Оборачивает Dispatch, чтобы каждый вызов происходил в нужном contextClassLoader’е - тот, в котором доступны юзерские тесты, аннотации @Test и прочие артефакты сборки. В противном случае рефлексия просто не увидит нужные классы. Комментарий: используется на стороне worker-а для того, чтобы внутри JUnit-кода применялся корректный ClassLoader, видящий классы тестов, их зависимости и runtime-окружение.

ReflectionDispatch - финальный обработчик, вызывающий метод

public class ReflectionDispatch implements Dispatch<MethodInvocation> {
    private final Object target;

    @Override
    public void dispatch(MethodInvocation message) {
        try {
            Method method = message.getMethod();
            method.setAccessible(true);
            method.invoke(target, message.getArguments());
        } catch (InvocationTargetException e) {
            throw UncheckedException.throwAsUncheckedException(e.getCause());
        } catch (Throwable throwable) {
            throw UncheckedException.throwAsUncheckedException(throwable);
        }
    }
}

Это последний шаг цепочки вызова: приходит MethodInvocation, и метод вызывается на target-объекте. Обычно это и есть JUnitTestClassProcessor, у которого вызывается processTestClass(...). Комментарий: работает внутри TestWorker - принимает вызов из master-процесса, извлекает метод и аргументы, и вызывает нужный метод на TestClassProcessor, фактически начиная выполнение теста.

SuiteTestClassProcessor - обёртка над настоящей обработкой класса

public class SuiteTestClassProcessor implements TestClassProcessor {
    private final TestClassProcessor processor;

    @Override
    public void processTestClass(TestClassRunInfo testClass) {
        ...
        processor.processTestClass(testClass);
        ...
    }
}

Именно здесь вызывается processTestClass(...) на реальном обработчике - чаще всего это JUnitTestClassProcessor, и уже он инициализирует JUnit Engine, Discovery, и начинает сканировать аннотации @Test. Комментарий: SuiteTestClassProcessor добавляет fault-tolerance-обёртку над реальным TestClassProcessor. Он отлавливает исключения выполнения и репортит их через resultProcessor, чтобы обеспечить корректную отчётность, даже если тесты упали на фазе запуска.

AbstractJUnitTestClassProcessor - общая логика для JUnit-обработчиков

public abstract class AbstractJUnitTestClassProcessor implements TestClassProcessor {

    private Action<String> executor;

    @Override
    public void startProcessing(TestResultProcessor resultProcessor) {
        executor = createTestExecutor(resultProcessorActor);
    }

     @Override
     public void processTestClass(TestClassRunInfo testClass) {
         executor.execute(testClass.getTestClassName());
     }
}

Далее переменной executor присваивается результат функции createTestExecutor(...), а в методе processTestClass(...) идёт обращение к этому executor. Комментарий: AbstractJUnitTestClassProcessor содержит шаблонную реализацию processTestClass(...), делегируя выполнение Action<String> - это и есть фактический механизм исполнения теста. Конкретные реализации определяют, как именно запускать класс.

JUnitPlatformTestClassProcessor - реализация под JUnit 5

public class JUnitPlatformTestClassProcessor extends AbstractJUnitTestClassProcessor {

    @Override
    protected Action<String> createTestExecutor(Actor resultProcessorActor) {
        ...
        testClassExecutor = new CollectAllTestClassesExecutor(threadSafeResultProcessor);
        return testClassExecutor;
    }
}

JUnitPlatformTestClassProcessor наследуется от AbstractJUnitTestClassProcessor и предоставляет реализацию метода createTestExecutor(...). Комментарий: на фазе запуска (в startProcessing) создаётся CollectAllTestClassesExecutor, который временно накапливает все классы, помеченные к исполнению. Позже Gradle вызовет stopProcessing(), и тогда накопленные классы передадутся в JUnitPlatform через Launcher.execute(...).

CollectAllTestClassesExecutor - накопление тестовых классов до момента исполнения

private class CollectAllTestClassesExecutor implements Action<String> {
    private final List<Class<?>> testClasses = new ArrayList<>();

    @Override
    public void execute(@Nonnull String testClassName) {
        Class<?> klass = loadClass(testClassName);
        testClasses.add(klass);
    }
}

Тут klass добавляется в список testClasses. Комментарий: CollectAllTestClassesExecutor не запускает тесты немедленно. Он просто накапливает их для дальнейшего пакетного исполнения. Позднее, в методе stopProcessing(), список будет передан JUnit Launcher, чтобы протестировать все классы единым TestPlan.

Почему мы всё это рассмотрели?

Если вы дочитали до этого момента, то наверняка хотя бы раз задали себе вопрос: зачем вообще вникать в архитектуру JUnit 5? Зачем знать, что делает TestWorker, чем TestEngine отличается от API, и как Gradle взаимодействует с JUnit Platform?

Ответ - в самом характере JUnit. Это не просто один из тестовых фреймворков. Это инфраструктура, на которой десятилетиями держалась JVM-разработка. JUnit - не просто библиотека. Это канонический пример того, как строятся фреймворки с глубокой интеграцией: в Gradle, в IDE, в CI. Поняв как работает JUnit, вы поймёте, как устроены почти все тестовые инструменты в JVM-мире - от Spock до Kotest, от TestNG до Spek.

Тесты на основе аннотаций всегда работают через рефлексию. Всегда есть рантайм, который анализирует классы, определяет методы, оборачивает их в структуру исполнения. Всегда есть TestEngine, который решает, как именно вызвать @BeforeEach, что считать failed-стейтом, и как прервать иерархию при падении родителя. Именно Engine - а не @Test и не Assertions - определяет поведение.

JUnit Platform отделила Engine от API. Это был стратегический шаг. Теперь JUnit - это не библиотека с аннотациями. Это инфраструктурный протокол, к которому могут подключаться любые движки. Jupiter - один из них. Kotest - другой. Даже ваш собственный.

Мы прошли весь путь: от команды ./gradlew test до самого вызова вашего метода. По дороге были:

  • Gradle, который координирует процесс;
  • Forked Worker, запускающий процесс с изоляцией;
  • TestClassProcessor, создающий структуру исполнения;
  • JUnit Platform, связывающая мир движков и инфраструктуры;
  • JUnit Jupiter, реализующий правила JUnit 5;
  • Hierarchical Test Executor, обрабатывающий before/execute/after как дерево;
  • и, наконец, конкретный метод, вызываемый через рефлексию.

Это выглядит сложнее, чем assertEquals(4, 2 + 2), но эта сложность - осмысленная. Она обеспечивает модульность, расширяемость, гибкость и параллелизм. Она позволяет использовать свои движки, свои жизненные циклы, свои правила. И именно это знание отличает пользователя фреймворка от инженера.

@VisibleForTesting: доступ, расширенный исключительно для тестов

Стоит упомянуть про аннотацию @VisibleForTesting нечастого, но важного участника тестового ландшафта. Она не делает метод тестом, не участвует в рантайме и никак не влияет на выполнение. Но если вы видите её в коде значит, где-то произошла честная сделка между инкапсуляцией и тестируемостью. Компромисс. Внятно обозначенный.

Аннотация сигнализирует: этот метод, это поле или конструктор открыты шире, чем должны быть но не потому, что «так проще», а потому, что тест этого требует. Приватную логику не протестируешь напрямую, а выносить её наружу в API не хочется. Тогда разработчик идёт на аккуратное нарушение границы и оставляет пометку @VisibleForTesting. Это как ручка от сейфа, которую поставили снаружи исключительно для инженеров техподдержки.

Такой подход особенно распространён в Android и Kotlin-проектах, где модификатор internal или protected часто становится компромиссной зоной. Метод мог бы быть private, но тогда тесты не смогут его вызвать. Расширяем видимость, но делаем это явно и документированно:

@VisibleForTesting
internal fun recalculateChecksum() {
    // метод открыт не для продакшена, а чтобы тест мог вызвать напрямую
}

У аннотации нет единого источника. Первая её версия появилась в Guava: com.google.common.annotations.VisibleForTesting. Затем аналогичные появились в androidx.annotation и org.jetbrains.annotations с тем же смыслом, но немного разной реализацией. Например, Android-версия допускает уточнение, какую видимость следует использовать в продакшене (otherwise = PRIVATE), если вы вдруг забудете вернуть модификатор обратно.

Важно понимать: компилятору всё равно. JUnit тем более. Это не инструкция, а честная метка. Обещание, данное себе и команде: “я знаю, что это дырка в инкапсуляции и сделал её не по глупости”. Именно такие детали отличают инженерное решение от хаотичного «лишь бы работало».

Почему JUnit 5 это уже не просто про юниты

Когда говорят «JUnit 5», чаще всего имеют в виду просто современную библиотеку для написания юнит-тестов. И это правда JUnit 5 вполне можно использовать как обычный инструмент: подключить, написать тесты, запускать из IDE или Gradle.

Но это поверхностный взгляд. Потому что JUnit 5 это не просто библиотека. Это фреймворк, из которого можно собирать другие фреймворки. Внутри него JUnit Platform, модуль, который работает как рантайм-движок: он не диктует, как должны выглядеть тесты, он умеет обнаруживать, исполнять и агрегировать любые тестовые единицы, если они реализованы в рамках платформенного контракта.

На этом контракте уже сегодня построено множество других библиотек. Они не используют JUnit «как есть», они строят свои абстракции, свои DSL’ы, но под капотом всё это всё равно исполняется через JUnit Platform. Ниже несколько ярких примеров:

Kotest Фреймворк, который кардинально переосмысливает, как должен выглядеть тест в языке Kotlin. Вместо привычной структуры “класс + методы”, здесь декларативный DSL, приближённый к структуре документации: “should do something” внутри “describe this behavior”. Kotest также встроенно поддерживает property-based testing, матчер-DSL, тестовые генераторы и детерминированные стратегии повторения. Но что важно всё это работает через JUnit Platform, а значит, совместимо с IDE, Gradle и CI из коробки.

Spek Минималистичный фреймворк, ориентированный на спецификацию поведения, а не реализацию. Тесты в Spek пишутся как цепочки вложенных блоков given / on / it, приближаясь к форме технического задания. В отличие от Kotest, Spek делает акцент на чистоте и лаконичности, особенно в микросервисных сценариях. И снова под всей этой структурой лежит не собственный раннер, а именно JUnit Platform.

Cucumber BDD-фреймворк, который строится вокруг Gherkin-сценариев. Вы описываете поведение системы в .feature-файле на естественном языке, а реализацию шагов связываете с кодом. Для запуска этих сценариев, Cucumber использует свой адаптер, который регистрирует себя как TestEngine JUnit Platform. Это позволяет запускать BDD-тесты в той же среде, что и обычные unit или integration тесты, не ломая пайплайн.

JQwik Property-based testing на Java и Kotlin, вдохновлённый QuickCheck. Вместо написания конкретных примеров, вы описываете свойства, которые система должна удовлетворять для произвольных входов. Генерация данных, shrink’инг, случайность всё встроено. И опять же: он не делает свою экосистему он интегрируется через JUnit Platform.

JUnit 5 это не вершина тестирования. Это скорее почва, на которой растут разные формы тестирования: декларативные, спецификационные, BDD, property-based. И возможность строить поверх него это не побочный эффект, это основа архитектурного замысла.

Это не библиотека для “написать тест и забыть”, это платформа, к которой можно подключить своё видение того, что такое тест вообще.

7. Kotlin Test: Строгая минималистика или Kotest

Когда разработчик впервые сталкивается с тестами в Kotlin, особенно на JVM, он почти автоматически тянется к тому, что уже знакомо - JUnit. Возможно, даже не подозревая, что в стандартной библиотеке Kotlin есть свой собственный тестовый модуль - kotlin.test. Не библиотека на GitHub, не очередной фреймворк от энтузиастов, а часть самого языка. Устанавливать его не нужно, он поставляется вместе со стандартной библиотекой, и уже доступен в любом Kotlin-проекте.

Описание фото

Если открыть любую multiplatform-библиотеку JetBrains - например, kotlinx.coroutines или kotlinx.datetime - можно увидеть, что все тесты написаны на kotlin.test: @Test, assertEquals, assertFailsWith. И всё это без упоминания JUnit. Это не случайность. kotlin.test был задуман как единый абстрактный слой для тестирования во всех таргетах: JVM, JS, Native. Он не привязан к JUnit, Mocha или XCTest - но умеет работать с каждым из них, если ты подключаешь соответствующий адаптер. На JVM это может быть kotlin-test-junit,kotlin-test-junit5, kotlin-test-testng. На JavaScript - интеграция с Mocha. На Native - своя реализация, написанная на C interop. API при этом остаётся единым.

Под капотом, конечно, никакого волшебства нет: assertEquals делегирует на org.junit.jupiter.api.Assertions.assertEquals только если ты сам подключил kotlin-test-junit5. Нет JUnit будет NoClassDefFoundError. Никаких запасных планов. kotlin.test это не runtime-инфраструктура, а чисто API-слой, такой же как kotlin.collections. Kotlin предоставляет общее имя, реализация зависит от того, на какой платформе ты находишься и что положил в classpath.

Именно поэтому kotlin.test часто недооценивают: на JVM он не кажется самостоятельным, выглядит как прокладка, иногда как костыль. Но за этим решением стоит другая философия не расширять JUnit, а уйти от него. Сделать API тестирования таким же, как стандартные функции в Kotlin: лаконичным, предсказуемым, платформонейтральным. У Kotlin нет своего @Synchronized, своего List или File, потому что есть JVM-шные аналоги. Но у него есть kotlin.test.assertEquals и это не просто синтаксический сахар.

Пример минимального теста в духе этой философии:

@Test
fun testSum() {
    assertEquals(4, 2 + 2)
}

Без @RunWith, без @DisplayName, без assertThat(...).isEqualTo(...). Просто функция, просто проверка. Как будто ты пишешь не тест, а обычный код и это ключевой замысел. Не выносить тестирование в отдельный мир с ритуалами и аннотациями, а встраивать его в обычную практику разработки, сделать его естественным, как println().

Но у этой строгости есть и предел. В какой-то момент тебе хочется группировать тесты по контексту, писать beforeEach, использовать property-based testing, делать ассерт не одного значения, а целой структуры. И вот здесь начинается другая история - история Kotest.

Kotest - лучшая Kotlin First тестовая библиотека?

Kotest - это не обёртка над kotlin.test и не альтернатива JUnit в классическом смысле. Это попытка переосмыслить саму структуру тестов, убрав их из мира фреймворков и вернуть в язык. Не симулировать JUnit DSL с Kotlin-нотками, а написать тесты как код, а не как декларации.

В Kotest нет @Test. Вместо этого - структуры и декларативные конструкции. Спецификации (FunSpec, DescribeSpec, BehaviorSpec и другие) задают форму теста: ты не навешиваешь аннотацию на метод, ты описываешь, как ведёт себя система в определённом контексте. DSL делает это без ceremony.

class MathSpec : FunSpec({
    test("2 + 2 should be 4") {
        2 + 2 shouldBe 4
    }
})

Это не просто коротко. Это читается как спецификация. Kotest позволяет писать тесты в форме, близкой к документации, но без потери точности или контроля над структурой. Ты явно задаёшь контекст, ожидаемое поведение, границы. Это не набор методов - это дерево.

У каждого стиля (Spec) - своя семантика. FunSpec - минимализм, DescribeSpec - BDD, ShouldSpec - поведенческое описание, WordSpec - текстовая вложенность, FreeSpec - произвольная свобода, ExpectSpec - JUnit-стиль. Это не косметика - это разные способы мыслить о тестах.

Kotest работает через TestEngine, который интегрируется с JUnit Platform. Без JUnit Platform он не запускается - и это принципиальный момент: Kotest не отвергает JUnit, он использует его как точку входа, но полностью заменяет внутреннюю механику. Это не костыль поверх JUnit 5, а самостоятельный фреймворк, который использует JUnit только как bootstrap и канал для IDE и Gradle.

Каждый тест может быть suspend. Корутинные тесты не требуют никаких обёрток или расширений. delay, Flow, runBlockingTest - всё работает из коробки. Это особенно важно, если ты тестируешь то, что и так построено на suspend-функциях. В kotlin.test это возможно, но не на всех уровнях одинаково гладко.

Matchers в Kotest - отдельная тема. Ты не делаешь assertEquals, ты пишешь value shouldBe expected. Или list shouldContainExactly listOf(...). Или exception shouldHaveMessage "Invalid state". Это не просто синтаксис. Это способ избавиться от лишнего уровня абстракции: не писать “assert X о Y”, а описывать свойства Y как есть.

Существует assertSoftly, которое позволяет в одном тесте проверять сразу несколько свойств и видеть все ошибки сразу, а не только первую. Это удобно, когда ты тестируешь сложный объект и не хочешь писать пять отдельных тестов на каждое поле.

Kotest также поддерживает property-based testing через forAll, checkAll, Gen. Это ближе к QuickCheck, чем к JUnit. Тесты становятся не списком кейсов, а генератором гипотез. В мире строго типизированного языка с мощным компилятором - это логичное продолжение.

class EmailValidatorTest : StringSpec({
    "should only accept valid emails" {
        forAll(Gen.email()) { email ->
            isValidEmail(email)
        }
    }
})

Kotest можно использовать на JS и Native, но не с той же глубиной. Его настоящая сила в JVM. Именно там ты получаешь весь спектр: DSL, корутины, матчеры, генераторы, спецификации, soft-asserts, глобальные конфигурации, hooks и extensions. Это не просто библиотека, это целая тестовая среда.

Но есть и границы. Kotest сложнее стартовать. Он не такой лёгкий, как kotlin.test. Его интеграция с IDE не всегда бесшовна. Иногда нужно понимать, как устроен lifecycle, особенно если ты пишешь расширения или работаешь с Before/After-hooks. Документация покрывает многое, но не всё. Если ты новичок в Kotlin или хочешь просто проверить, что функция возвращает true тебе, возможно, не нужен весь этот DSL.

И вот здесь и возникает выбор. Если тебе нужно писать простые тесты быстро и платформонейтрально kotlin.test даст всё, что нужно. Он строг, предсказуем и не требует инфраструктуры. Но если ты хочешь описывать поведение системы в терминах, близких к языку, если тебе важна выразительность, вложенность, property-based тестирование, гибкая конфигурация и DSL, написанный именно для Kotlin Kotest берёт это всё на себя. Не по лицензии. По замыслу.

И делает это лучше всех на JVM.

8. Заключение: Тесты как мышление, а не как процедура

Мы прошли путь от assertEquals(expected, actual) до архитектуры JUnit Platform. От простого сравнения двух значений до понимания того, как работает вся инфраструктура тестирования на JVM. Это не случайность. Тестирование начинается с философии, а заканчивается инженерией.

Если вы запомните из этой статьи только одну мысль, пусть это будет следующая: тест - это не проверка, а модель. Когда вы пишете assertTrue(user.isActive()), вы не проверяете, активен ли пользователь в реальности. Вы фиксируете соглашение о том, что в заданных условиях свойство isActive должно возвращать true. Это контракт между вами и системой. И когда тест падает - нарушается не “истина”, а именно этот контракт.

Test Doubles - stub, mock, fake, spy - это не просто технические приёмы. Это способы построения контролируемых миров, где можно изолированно проверить отдельные аспекты поведения. Выбор между ними отражает ваш подход к архитектуре: что важно контролировать, что можно упростить, а что необходимо сохранить в первозданном виде.

JUnit прошёл эволюцию от процедурного фреймворка к платформе. Сегодня JUnit 5 - это не просто библиотека для написания unit-тестов, это инфраструктура, на которой строятся другие подходы к тестированию: от Kotest с его выразительным DSL до Cucumber с BDD-сценариями. Понимание этой архитектуры даёт возможность не только использовать готовые решения, но и создавать свои.

Kotlin привносит в тестирование ту же философию, что и в основной код: лаконичность без потери выразительности, строгость без излишней ceremony. kotlin.test даёт платформонейтральный минимум. Kotest - богатую экосистему для тех, кто хочет описывать поведение системы как спецификацию, а не как набор процедур.

Но главное не в выборе инструментов. Главное - в понимании того, что вы тестируете и зачем. Каждый @Test - это гипотеза о поведении системы. Каждый assert - это граница между допустимым и недопустимым. И чем яснее эти границы, тем надёжнее система и тем проще её развивать.

В следующих частях серии мы углубимся в практические аспекты: как работают популярные фреймворки для тестирования, как работает тестовая инфраструктура под капотом, как избежать иллюзий стабильности и построить пирамиду тестов, которая действительно работает. Но основа уже заложена. Тесты - это не проверка того, что код работает. Это способ формализовать то, как он должен работать. И если вы это понимаете - вы уже на правильном пути.

Обсуждение

Комментарии