Перейти к содержанию
V1 V2

Компонентные, интеграционные и чёрно-ящичные тесты в Kora

5 августа 2026

Стратегию тестирования часто сводят к ярлыкам. Одна команда называет тест «юнит», потому что он работает в JUnit. Другая называет тот же тест «интеграционным», потому что запускается контейнер внедрения зависимостей. Третья называет каждый HTTP-тест «сквозным», даже когда приложение работает в том же процессе с замоканной половиной зависимостей. Эти ярлыки не говорят нам, что доказывает тест.

Фреймворк Kora предлагает более полезный способ проводить границы. Его документация по тестированию рассматривает компонентные, интеграционные и чёрно-ящичные тесты как отдельные слои, каждый из которых построен под свой вопрос. Компонентные тесты запускают выбранную часть графа приложения Kora и могут заменять зависимости. Интеграционные тесты всё ещё работают внутри тестового процесса и вызывают управляемые графом компоненты напрямую, но сохраняют реальную инфраструктурную границу, такую как PostgreSQL. Чёрно-ящичные тесты запускают упакованное приложение как внешний процесс или контейнер и взаимодействуют с ним только через его публичный API.

Различие не просто в том, сколько кода выполняется. Оно в том, где тесту разрешено наблюдать и контролировать систему.

Компонентный тест может внедрить UserService и мок репозитория. Интеграционный тест может внедрить UserService и реальный сгенерированный репозиторий, подключённый к базе данных Testcontainers. Чёрно-ящичный тест не должен знать ни один из этих классов. Он отправляет POST /users, читает HTTP-ответ и видит сервис так, как его видит клиент.

Эти слои образуют практическую пирамиду:

                 Чёрный ящик
                     мало

                Интеграция
                   меньше

                 Компонент
                    много

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

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

Одна архитектура приложения, три границы наблюдения

Kora строит и валидирует свой граф приложения на этапе компиляции. Компоненты, модули, методы-фабрики, теги, сгенерированные репозитории, мапперы конфигурации, контроллеры, клиенты и отношения жизненного цикла становятся одной явной структурой. Продакшн запускает этот сгенерированный граф. JUnit-расширение Kora также может использовать его как источник для меньшего тестового графа.

Предположим, запрос на создание пользователя следует этому пути:

HTTP-запрос
    |
UserController
    |
UserService
    |
UserRepository
    |
JdbcDatabase
    |
PostgreSQL

Все три типа тестов могут отрабатывать создание пользователя, но они прорезают эту структуру в разных местах.

Компонентный тест входит на UserService. Он может заменить UserRepository моком, поэтому проверяет решения сервиса без SQL или HTTP. Интеграционный тест также входит на UserService, но сохраняет реальный репозиторий, пул JDBC, миграции, маппинг и экземпляр PostgreSQL. Чёрно-ящичный тест входит через публичный HTTP-порт. Он пересекает маршрутизацию, маппинг запросов, валидацию, поведение контроллера, логику сервиса, код репозитория, доступ к базе данных, маппинг ответов и конфигурацию контейнера.

Движение вверх по пирамиде увеличивает объём реальности, включённой в тест. Оно также увеличивает число возможных причин сбоя. Вот почему слои дополняют, а не заменяют друг друга. Широкий тест авторитетен, но может быть медленнее в диагностике. Узкий тест точен, но может устанавливать факты только внутри своей границы.

Компонентные тесты: быстрая обратная связь через срез графа

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

@KoraAppTest(Application.class)
class UserServiceComponentTest {

    @Mock
    @TestComponent
    private UserRepository userRepository;

    @TestComponent
    private UserService userService;

    @Test
    void returnsAnExistingUser() {
        var expected = new User("42", "Ada", "ada@example.com");
        when(userRepository.findById("42"))
            .thenReturn(Optional.of(expected));

        var result = userService.getUser("42");

        assertEquals(Optional.of(expected), result);
        verify(userRepository).findById("42");
    }
}
@KoraAppTest(Application::class)
class UserServiceComponentTest {

    @MockK
    @TestComponent
    lateinit var userRepository: UserRepository

    @TestComponent
    lateinit var userService: UserService

    @Test
    fun returnsAnExistingUser() {
        val expected = User("42", "Ada", "ada@example.com")
        every { userRepository.findById("42") } returns Optional.of(expected)

        val result = userService.getUser("42")

        assertEquals(Optional.of(expected), result)
        verify { userRepository.findById("42") }
    }
}

@KoraAppTest(Application.class) выбирает сгенерированный граф приложения. @TestComponent на UserService делает этот компонент корнем тестового графа, поэтому Kora сохраняет сервис и зависимости, необходимые для его создания. Поле репозитория несёт и @Mock из Mockito, и @TestComponent из Kora. Получившийся мок не только доступен тесту; он заменяет узел репозитория, используемый UserService.

Это вернее, чем вызов new UserService(repositoryMock) в каждом тесте. Kora всё ещё доказывает, что UserService принадлежит графу приложения, что его зависимость может быть разрешена в ожидаемой позиции графа и что компонент создаётся через продакшн-модель соединения. В то же время замена репозитория убирает реальную ветвь JDBC из сохранённого замыкания зависимостей. HTTP-сервер и контроллер также остаются вне среза, потому что тест их не запросил.

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

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

Kora держит такие тесты быстрыми, потому что инициализируются только запрошенные корни графа и их транзитивные зависимости. По умолчанию жизненный цикл JUnit PER_METHOD даёт каждому тестовому методу свежий граф, а Kora освобождает управляемые жизненным циклом компоненты после теста. PER_CLASS может разделять один граф, когда инициализация дорога, но тогда изменяемое состояние нужно очищать явно, а моки сбрасываются перед каждым методом.

То, чего компонентный тест не доказывает, не менее важно. Мок репозитория не выполняет сгенерированный SQL. Замоканный HTTP-клиент не доказывает поведение заголовков, пути, тела или TLS. Прямой вызов сервиса обходит HTTP-маршрутизацию, JSON-преобразование, валидацию контроллера, промежуточное ПО и маппинг кодов статусов. Встроенная тестовая конфигурация не доказывает, что упакованное приложение корректно получает своё окружение. Компонентные тесты дают быструю уверенность в управляемом графом поведении, а не уверенность в развёртывании.

Используйте компонентный тест, когда ответ остался бы значимым, если бы внешняя инфраструктура была идеально симулирована. Если баг мог существовать только в SQL, сетевом протоколе, сериализации, запуске процесса или упаковке, тест принадлежит выше в пирамиде.

Интеграционные тесты: реальная инфраструктура, прямой доступ к компонентам

Интеграционный тест сохраняет реальными код приложения и одну или несколько инфраструктурных границ. В руководствах Kora канонический пример запускает контейнер PostgreSQL, строит тестовый граф Kora, содержащий реальный пул JDBC, миграции Flyway, сгенерированный репозиторий и UserService, а затем вызывает эти управляемые графом компоненты напрямую из JUnit.

JUnit-тест
    |
    | прямой вызов метода
    v
UserService
    |
сгенерированный UserRepository
    |
JdbcDatabase
    |
PostgreSQLContainer

HTTP-слой намеренно отсутствует. Тест не спрашивает, может ли клиент создать пользователя. Он спрашивает, корректно ли ведёт себя код приложения Kora с фактической семантикой PostgreSQL.

Testcontainers создаёт одноразовую базу данных. KoraAppTestConfigModifier поставляет её URL JDBC, имя пользователя и пароль до построения графа. Затем Kora создаёт из этих значений обычные продакшн-компоненты.

@Testcontainers
@KoraAppTest(TestApplication.class)
class UserRepositoryIntegrationTest implements KoraAppTestConfigModifier {

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @TestComponent
    private UserService userService;

    @TestComponent
    private TestUserRepository testUserRepository;

    @Override
    public KoraConfigModification config() {
        return KoraConfigModification.ofString("""
                jdbc {
                  jdbcUrl = ${POSTGRES_JDBC_URL}
                  username = ${POSTGRES_USER}
                  password = ${POSTGRES_PASS}
                  poolName = "kora-test"
                }
                flyway.locations = "db/migration"
                """)
            .withSystemProperty("POSTGRES_JDBC_URL", POSTGRES.getJdbcUrl())
            .withSystemProperty("POSTGRES_USER", POSTGRES.getUsername())
            .withSystemProperty("POSTGRES_PASS", POSTGRES.getPassword());
    }
}
@Testcontainers
@KoraAppTest(TestApplication::class)
class UserRepositoryIntegrationTest : KoraAppTestConfigModifier {

    @TestComponent
    lateinit var userService: UserService

    @TestComponent
    lateinit var testUserRepository: TestUserRepository

    override fun config(): KoraConfigModification {
        return KoraConfigModification.ofString(
            """
            jdbc {
              jdbcUrl = ${'$'}{POSTGRES_JDBC_URL}
              username = ${'$'}{POSTGRES_USER}
              password = ${'$'}{POSTGRES_PASS}
              poolName = "kora-test"
            }
            flyway.locations = "db/migration"
            """.trimIndent()
        )
            .withSystemProperty("POSTGRES_JDBC_URL", POSTGRES.jdbcUrl)
            .withSystemProperty("POSTGRES_USER", POSTGRES.username)
            .withSystemProperty("POSTGRES_PASS", POSTGRES.password)
    }

    companion object {
        @Container
        @JvmStatic
        val POSTGRES = PostgreSQLContainer("postgres:16-alpine")
    }
}

Поскольку конфигурация применяется до инициализации графа, пул JDBC и миграции запускаются против базы данных контейнера, а не патчатся задним числом. Реализации репозиториев генерируются и соединяются так же, как в приложении. Тесты поэтому могут валидировать выполнение SQL, привязку параметров, маппинг строк, ограничения, транзакции, сортировку, пагинацию, обновления, удаления и совместимость миграций.

Интеграционные тесты нужны всякий раз, когда семантика инфраструктуры — часть ожидаемого поведения. Сервис, зависящий от уникального ограничения базы данных, нельзя полностью протестировать, запрограммировав мок выбросить исключение, которое реальный драйвер может никогда не выбросить в такой форме. Алгоритм пагинации может выглядеть корректным против списка, но ломаться, потому что порядок SQL недетерминирован. Миграция может создать колонку с типом, который сгенерированный маппер не может прочитать. HTTP-клиент может построить неверный URL или неправильно замапить тело удалённой ошибки. Это факты границы, поэтому граница должна существовать.

Тестовый граф Kora позволяет интеграционному тесту оставаться уже полного приложения. Тест может сохранить ветвь базы данных, не привязывая публичный HTTP-сервер, не вступая в группу потребителей Kafka и не запуская несвязанные фоновые задачи. Прямой доступ к UserService или UserRepository делает установку и утверждения краткими, а сбои обычно указывают на исследуемую инфраструктурную границу.

Здесь полезны тестовые расширения графа. Руководство по интеграции объявляет TestApplication, который расширяет продакшн-Application и добавляет репозиторий с deleteAll() для очистки. Kora генерирует этот тестовый граф в тестовом наборе исходников. Это держит административные операции вне продакшн-репозиториев, позволяя самой очистке использовать реальный доступ к базе данных. Такие расширения должны содержать возможности, чьи соединение или жизненный цикл важны; обычные помощники утверждений и строители данных не должны становиться узлами графа.

Интеграционные тесты всё ещё не доказывают публичный контракт. Вызов userService.createUser(...) не может выявить, что POST /users отсутствует, что JSON-поле имеет неверное имя, что валидация отклоняет легитимное тело или что исключение маппится на 500 вместо 409. Тест также запускает компоненты приложения внутри JVM JUnit, а не из упакованного дистрибутива. Classpath, аргументы JVM, пользователь контейнера, открытые порты, переменные окружения и команда запуска могут отличаться от развёртывания.

Используйте интеграционный тест, когда центральный вопрос содержит фразу «с реальной базой данных», «по реальному протоколу», «с фактическими миграциями» или «через фактический сгенерированный клиент или репозиторий», но не требует публичной точки входа упакованного сервиса.

Чёрно-ящичные тесты: упакованное приложение снаружи

Чёрно-ящичный тест убирает привилегированный доступ. Он не внедряет UserService, не запрашивает KoraAppGraph, не заменяет зависимость и не вызывает метод контроллера. Он запускает полное упакованное приложение и общается через публичный HTTP API.

Руководство по чёрному ящику строит Gradle-дистрибутив приложения, создаёт из него Docker-образ и запускает этот образ через Testcontainers. PostgreSQL работает во втором контейнере в той же Docker-сети. Переменные окружения предоставляют обычную конфигурацию времени выполнения приложения. Тест ждёт системный эндпоинт готовности Kora перед отправкой запросов на публичный порт.

Процесс JUnit
    |
    | HTTP
    v
Контейнер приложения Kora ---- JDBC ----> Контейнер PostgreSQL
    |
    +-- публичный порт: тестируемый API
    |
    +-- системный порт: готовность, живость, метрики

Готовность — это больше, чем удобная задержка. Ожидание возврата 200 от GET /system/readiness использует тот же операционный сигнал, который оркестратор может использовать в продакшне. Это избегает хрупких sleep-ов и сопоставления сообщений лога. Готовый результат устанавливает, что граф приложения инициализировался и его пробы готовности допускают трафик; он не требует, чтобы тест знал, сколько времени должен занять запуск.

final class AppContainer extends GenericContainer<AppContainer> {

    AppContainer() {
        super(new ImageFromDockerfile("users-black-box")
            .withDockerfile(Path.of("../users-app/Dockerfile")));

        withExposedPorts(8080, 8085);
        waitingFor(
            Wait.forHttp("/system/readiness")
                .forPort(8085)
                .forStatusCode(200)
        );
    }

    URI publicUri() {
        return URI.create("http://" + getHost() + ":" + getMappedPort(8080));
    }
}
class AppContainer : GenericContainer<AppContainer>(
    ImageFromDockerfile("users-black-box")
        .withDockerfile(Path.of("../users-app/Dockerfile"))
) {

    init {
        withExposedPorts(8080, 8085)
        waitingFor(
            Wait.forHttp("/system/readiness")
                .forPort(8085)
                .forStatusCode(200)
        )
    }

    fun publicUri(): URI {
        return URI.create("http://$host:${getMappedPort(8080)}")
    }
}

Как только контейнер готов, тест ведёт себя как внешний клиент:

@Test
void createsAndReturnsAUser() throws Exception {
    var request = HttpRequest.newBuilder()
        .uri(APP.publicUri().resolve("/users"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("""
            {"name":"Ada","email":"ada@example.com"}
            """))
        .build();

    var response = HttpClient.newHttpClient()
        .send(request, HttpResponse.BodyHandlers.ofString());

    assertEquals(201, response.statusCode());
    assertTrue(response.body().contains("\"name\":\"Ada\""));
}
@Test
fun createsAndReturnsAUser() {
    val request = HttpRequest.newBuilder()
        .uri(APP.publicUri().resolve("/users"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(
            """
            {"name":"Ada","email":"ada@example.com"}
            """.trimIndent()
        ))
        .build()

    val response = HttpClient.newHttpClient()
        .send(request, HttpResponse.BodyHandlers.ofString())

    assertEquals(201, response.statusCode())
    assertTrue(response.body().contains("\"name\":\"Ada\""))
}

Самые сильные чёрно-ящичные тесты избегают импорта DTO приложения. Если и приложение, и тест сериализуют один и тот же Java-record, переименование JSON-поля может изменить обе стороны и позволить сломанному публичному контракту пройти. Построение запроса и чтение ответа независимо делает тест подлинным клиентом. Проект приложения может оставаться зависимостью сборки, чтобы дистрибутив создавался до теста, но его внутренние классы не должны становиться API наблюдения теста.

Этот слой доказывает факты, которые нижние слои не могут увидеть вместе: дистрибутив запускаем; Dockerfile содержит и запускает его корректно; порты открыты и замаплены; конфигурация окружения достигает процесса; полный граф запускается; миграции выполняются; маршрутизация выбирает контроллер; тела запросов десериализуются; валидация и промежуточное ПО выполняются; сервисы и репозитории сотрудничают; ответы сериализуются с корректными кодами статусов, заголовками и телами; готовность отражает состояние запуска.

Чёрно-ящичные тесты — правильное место для репрезентативных клиентских путешествий и критичных для продакшна контрактов. Поток create-read-update-delete, граница аутентификации, идемпотентный платёжный запрос, контракт здоровья и готовности или критичный ответ об ошибке заслуживают проверки снаружи. Тесты должны включать успешное поведение и небольшой набор высокоценных случаев сбоя, особенно тех, чьё HTTP-представление — часть API.

Они плохие инструменты для исчерпывающего ветвления. Запуск контейнеров для проверки двадцати вариаций сервис-уровневого порога тратит время и затрудняет локализацию сбоев. Они также требуют дисциплинированной изоляции данных. Когда контейнеры приложения и базы данных разделяются тестовым классом, каждый сценарий должен создавать уникальные данные или намеренно очищать состояние. Случайные замапленные порты, явные таймауты запросов, логи контейнеров, живость, готовность и метрики делают сбои диагностируемыми, не ослабляя чёрно-ящичную границу.

Используйте чёрно-ящичный тест, когда вопрос начинается с «Может ли реальный клиент…?», «Будет ли упакованный сервис…?» или «Соответствует ли развёрнутый контракт…?». Если для ответа нужен прямой доступ к объекту приложения, это не чёрно-ящичный вопрос.

Следуя одному поведению вверх по пирамиде

Самый чистый способ понять разделение — проследить одно требование через все три слоя. Предположим, дублирующиеся адреса электронной почты должны отклоняться с HTTP-статусом 409.

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

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

На чёрно-ящичном уровне тест создаёт пользователя через POST /users, отправляет тот же адрес снова и ожидает 409 с публичным телом ошибки. Это доказывает весь контракт, включая JSON-декодирование, маппинг контроллера, поведение базы данных, трансляцию исключений и сериализацию ответов в упакованном сервисе. Одного репрезентативного сценария обычно достаточно, потому что перестановками уже владеют нижние слои.

Три теста не дубликаты. Они ломаются по разным причинам и устанавливают разные утверждения. Удаление компонентных случаев теряет дешёвое поведенческое покрытие. Удаление интеграционного случая заменяет истину базы данных симуляцией. Удаление чёрно-ящичного случая оставляет клиентский контракт выведенным, а не наблюдаемым.

Выбор слоя по сбою, который вы хотите обнаружить

Лучшая тестовая граница — самая узкая, которая содержит возможный дефект.

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

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

Если конфигурация меняет то, какая реализация входит в граф, компонентный тест с KoraAppTestConfigModifier может проверить выбранный срез. Если проблема в том, создают ли адреса времени выполнения Testcontainers пригодный пул JDBC, уместна интеграция. Если проблема в том, работают ли вместе переменные окружения Docker, файл конфигурации, команда запуска и открытый порт в итоговом образе, только чёрный ящик покрывает всё утверждение.

Это правило решения предотвращает две симметричные ошибки. Тестирование слишком низко даёт ложную уверенность, потому что релевантная поверхность сбоя была замоканa или обойдена. Тестирование слишком высоко даёт медленные, непрозрачные наборы, потому что каждое маленькое правило проталкивается через инфраструктуру и HTTP.

Сколько тестов принадлежит каждому уровню

Никакой фиксированный процент не подходит каждому сервису Kora. Сервис, тяжёлый на расчётах и с малой инфраструктурой, естественно имеет широкую компонентную базу. Тонкий API базы данных может нуждаться в пропорционально большем интеграционном покрытии. Публичный шлюз со сложной маршрутизацией и обязательствами совместимости может оправдать больше чёрно-ящичных контрактов. Пирамида — это давление в сторону экономного размещения, а не квота.

«Много» компонентных тестов означает, что обычная обратная связь при разработке не должна требовать Docker или упакованного приложения. Инженеры должны уметь покрывать новые ветви через маленькие срезы графа и получать сбои рядом с изменённым кодом.

«Меньше» интеграционных тестов означает, что поведение инфраструктуры сэмплируется по возможности, а не повторяется для каждой бизнес-перестановки. Одна группа может установить CRUD и маппинг репозитория. Другая может установить поведение транзакций. Ещё одна может установить клиентский протокол. Сценарии должны оправдывать свою стоимость контейнера и жизненного цикла, доказывая то, чего моки не могут.

«Мало» чёрно-ящичных тестов означает, что набор выбирает критичные публичные контракты и репрезентативные путешествия. Это не значит один поверхностный health-check. Полезный чёрно-ящичный набор должен доказывать запуск и готовность, по крайней мере один успешный путь через каждую критичную область API, важные представления ошибок и производственно-рисковые поведения, пересекающие многие слои. Его малый размер приходит от выбора высокоинформативных сценариев, а не от заниженных ожиданий.

Быстрый запуск Kora может сделать чёрно-ящичные тесты более практичными, чем во фреймворках с дорогим обнаружением графа времени выполнения. Это возможность улучшить уверенность в релизе, но не причина переносить всю логику-тестирование вверх. Быстрый полный запуск снижает одну стоимость; контейнеры, сети, базы данных, сборки образов, очистка данных и широкая диагностика сбоев всё ещё остаются.

Конфигурация и жизненный цикл меняются между слоями

Всем трём слоям нужна конфигурация, но они должны получать её на своей естественной границе.

Компонентные тесты используют KoraConfigModification до создания тестового графа. Встроенный фрагмент HOCON может выразить один сценарий, тестовый ресурс может содержать переиспользуемый профиль, а временные системные свойства могут заполнять подстановки. Только компонентам, сохранённым срезом графа, нужна пригодная конфигурация.

Интеграционные тесты также конфигурируют внутрипроцессный граф, но значения обычно берутся из геттеров реальной инфраструктуры. Контейнер PostgreSQL владеет своим адресом и учётными данными; тест передаёт их в граф, а не предполагает localhost:5432. Kora инициализирует пулы, миграции и репозитории из этих значений и освобождает ресурсы графа, когда тестовый контекст завершается.

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

Жизненный цикл следует той же прогрессии. Компонентный граф обычно запускается на метод и содержит мало ресурсов. Интеграционный граф может разделять статический инфраструктурный контейнер, пересобирая компоненты Kora, или использовать PER_CLASS, когда запуск пула и миграций доминирует. Чёрно-ящичный класс часто разделяет контейнеры приложения и базы данных между сценариями, ждёт готовности один раз и обеспечивает изоляцию данных внутри этой общей среды. Верхние слои экономят больше времени через переиспользование, но также накапливают больше изменяемого состояния, поэтому очистка становится важнее.

Распространённые способы исказить пирамиду

Компонентный тест становится вводящим в заблуждение, когда мокируется каждая зависимость, хотя дешёвые реальные компоненты укрепили бы срез графа. Мокайте внешние границы и недетерминированных соавторов, а не каждый параметр конструктора по рефлексу.

Интеграционный тест становится замаскированным компонентным тестом, когда он запускает PostgreSQL, но мокает репозиторий, чей SQL якобы исследуется. Он становится замаскированным чёрно-ящичным тестом, когда запускает полный сервер, но всё ещё лезет внутрь графа за утверждениями. Называйте границу честно и держите точки контроля на одной её стороне.

Чёрно-ящичный тест перестаёт быть чёрным ящиком, когда импортирует DTO приложения, вызывает методы очистки внутри репозиториев приложения, заменяет узлы графа или читает внутренности сгенерированного графа. Внешняя проверка базы данных может быть полезна для диагностики, но первичные утверждения должны оставаться видимыми через публичное поведение, если только сама персистентность не является частью внешнего контракта.

Ещё одно искажение — трактовка пирамиды как порядка выполнения. Компонентные тесты обычно должны запускаться при каждом локальном изменении и изменении CI. Интеграционные тесты могут запускаться в основном конвейере CI, когда доступен Docker, возможно, сгруппированными для переиспользования инфраструктуры. Чёрно-ящичные тесты должны запускаться после сборки артефакта приложения и до того, как он считается релизопригодным. Более быстрые smoke-подмножества могут запускаться во время pull request-ов, с полным внешним набором на шлюзе релиза.

Флаки высокоуровневых тестов — не неизбежная цена реализма. Фиксированные sleep-ы, захардкоженные порты, разделяемые идентификаторы, неявный порядок запуска и зависимость от предыдущих сценариев создают большую часть нестабильности. Управляемый Testcontainers жизненный цикл, случайные замапленные порты, уникальные тестовые данные, явные HTTP-таймауты и ожидания на основе готовности делают окружение детерминированным, не залезая во внутренности приложения.

Сбалансированная стратегия тестирования Kora

Для каждой фичи начните с определения публичного обещания и рискованных границ. Поместите бизнес-перестановки в компонентные тесты, используя реальные управляемые графом сервисы и контролируемые замены. Добавьте интеграционные тесты там, где сгенерированный код или семантика внешней системы могли бы аннулировать симуляцию. Добавьте один или несколько чёрно-ящичных сценариев для публичного путешествия и его самого важного внешне видимого сбоя.

Во время разработки компонентные сбои должны быстро объяснять регрессии логики. Интеграционные сбои должны сужать внимание до SQL, маппинга, миграций, транзакций, клиентских протоколов или конфигурации инфраструктуры. Чёрно-ящичные сбои должны отвечать на вопрос релиза: артефакт может компилироваться и его части могут проходить, но может ли собранный сервис запуститься, стать готовым, принять реальный запрос, использовать реальную инфраструктуру и вернуть обещанный ответ?

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

Поэтому пирамида остаётся простой:

много компонентных тестов
    устанавливают поведение быстро

меньше интеграционных тестов
    устанавливают истину инфраструктуры

мало чёрно-ящичных тестов
    устанавливают развёртываемую, видимую клиенту истину

Её цель — не ранжировать тесты от наименее к наиболее ценным. Она в том, чтобы разместить каждое утверждение на самой дешёвой границе, способной его доказать. Kora делает эти границы необычно целостными, потому что все три слоя происходят из одной архитектуры приложения, а каждый слой добавляет ровно ту реальность, которой требует его вопрос.