Оглавление:

Пролог
Акт I
Акт II
Акт III
Акт IV
Акт V
Акт VI
Эпилог

Пролог: "Мы на проде, но тесты упали"

Я помню этот день. Семь вечера, я только что перешел в новую команду, и мы готовимся к релизу, а дашборд CI горит красным. Из 500 тестов 20 упали. Запускаю локально — проходят. Запускаю на другом сервере — снова падают. Flaky-тесты. Слак-канал:

  • Джун: "Тесты упали, что делать?"

  • Дев: "Ну, у меня локально всё зелёное, это, наверное, проблема CI".

  • Я: "Да, конечно, это проблема CI. Он просто решил меня унизить в первый день".

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

Акт I: Рождение хаоса. Атомарность, изоляция и чистая среда

Теория: Принцип AAA (Arrange-Act-Assert) и атомарность. Каждый тест — это короткая, законченная история. AAA помогает локализовать баг:

  • Arrange: подготовка данных (создание пользователя, логин).

  • Act: одно ключевое действие (добавление товара в корзину).

  • Assert: одна или несколько проверок результата.

Anti-pattern: "Тест-монстр" и "Тест-мусорщик"

На проекте я обнаружил тест, который ранее считался эталонным, но со временем стал причиной нестабильности в CI. Ниже представлена его супер упрощённая версия.

@Test
void loginAndCreateUserAndThenCheckOrdersAndMore() {
    // Arrange: логин и создание пользователя
    userPage.login("test@test.com", "password");
    userPage.createUser("john.doe");

    // Act: оформление заказа
    orderPage.placeOrder("book");

    // Assert: проверка нотификаций
    userPage.checkNotifications();

    // Где-то здесь тест умирает без объяснения...
    // ...и оставляет после себя данные
}

Боль: Этот "тест-монстр" проверяет три разных флоу, создает кучу данных и умирает без объяснения. Когда он падает, я не знаю, что сломалось: логин? создание? проверка? Отладка превращается в пытку.

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

Best practice: Атомарные тесты с изоляцией

Мы безжалостно разделили монстра. Каждый тест — одна история. И главное — каждый тест получил собственную песочницу. С помощью аннотаций @BeforeEach и @AfterEach в JUnit 5 (ссылка на docs.junit.org) мы автоматизировали создание и удаление данных для каждого теста, гарантируя, что они не мешают друг другу.

// Класс теста
class UserManagementTest {

    // Наш "пожарный", который убирает за каждым тестом
    private UserService userService = new UserService();

    // Этот метод запускается перед каждым тестом
    @BeforeEach
    void setup() {
        // Arrange: Создаём пользователя для конкретного теста
        userService.createUser("testUser");
    }

    // Этот метод запускается после каждого теста
    @AfterEach
    void cleanup() {
        // Очищаем: Удаляем пользователя, чтобы не мешать другим тестам
        userService.deleteUser("testUser");
    }

    @Test
    void userCanLoginWithValidCredentials() {
        // Arrange, Act, Assert — изолированно и чисто
        loginPage.login("testUser", "password");
        // ...
    }
}

Результат: Мы разделили один гигантский тест на 15 атомарных. Время на отладку сократилось на 80%. Мы больше не тратили часы, чтобы понять, что сломалось.

Акт II: Война с фреймворком. Page Object и стабильность

Теория: Page Object Pattern — выносит все локаторы в отдельный класс. Если UI меняется, ты меняешь локатор в одном месте, а не в 20 тестах.

Anti-pattern: Динамические локаторы и Thread.sleep()

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

// Ужасная правда: каждый раз приходилось менять этот локатор в 10 тестах
driver.findElement(By.id("login-btn-0987123")).click();

// Главный враг CI. Он НЕ ЖДЁТ, он спит.
// ❌ НЕ ДЕЛАЙ ТАК
Thread.sleep(5000);
driver.findElement(By.id("some-button")).click();

Боль: Я стал садовником, который каждый день пропалывает сорняки в чужом коде. Я тратил больше времени на исправление тестов, чем на написание новых. Моя жизнь утекала сквозь пальцы, и я чувствовал, как CI смеется мне в лицо. А Thread.sleep() был моим личным врагом. Он маскировал реальные проблемы с производительностью и асинхронностью, делая тесты нестабильными.

Best practice: Page Object с явными ожиданиями

Мы вынесли весь ад в Page Object и спрятали его за красивыми методами. А для ожидания начали использовать WebDriverWait, который ждёт, пока элемент действительно появится на странице.

// Page Object для страницы логина
public class LoginPage {

    private final WebDriver driver;
    private final WebDriverWait wait;

    public LoginPage(WebDriver driver) {
        this.driver = driver;
        this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
    }

    // Инкапсулируем локаторы
    private final By usernameField = By.id("username");
    private final By passwordField = By.id("password");
    private final By loginButton = By.xpath("//button[text()='Login']");

    // Элегантный метод с явными ожиданиями
    public HomePage loginAs(String username, String password) {
        // Ждем и вводим логин
        wait.until(d -> driver.findElement(usernameField).isDisplayed())
            .sendKeys(username);
        
        // Вводим пароль
        driver.findElement(passwordField).sendKeys(password);
        
        // Ждем кликабельности и кликаем
        wait.until(d -> {
            WebElement button = driver.findElement(loginButton);
            return button.isDisplayed() && button.isEnabled();
        }).click();
        
        // Возвращаем следующую страницу для Fluent API
        return new HomePage(driver);
    }
}

Элегантный код с Fluent Page Objects: используя возможности Fluent Interface в Selenium, мы сделали код более читаемым и лаконичным.

// До (разрозненные вызовы)
loginPage.loginAs("admin", "password");
profilePage.openOrders(); 
ordersPage.checkLastOrder();

// После (логическая группировка через Fluent Interface)
loginPage.loginAs("admin", "password")
         .navigateToOrders()
         .verifyLastOrderExists();

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

Акт III: Апокалипсис CI. Триумф над асинхронностью

Теория: Асинхронность и Eventual Consistency. Современные микросервисы общаются между собой асинхронно. Чтобы проверить их, нужно:

  • Explicit Waits: Ждать, пока элемент появится.

  • Polling: Заставить тест опрашивать систему, пока не получит нужный результат.

  • Интеграционные тесты: Проверять системы напрямую — Kafka, БД.

Anti-pattern: Ложь под названием @Retry и слепота

На проекте, куда я пришел, было уже 500 тестов. В архитектуре появились Kafka и RabbitMQ. CI-пайплайн горел красным.
«Тест упал. Почему? Не знаю. Может, Kafka что-то не отправила. Или в базе что-то не обновилось.»

Я обнаружил, что для "решения" этой проблемы кто-то добавил @Retry(3). И тесты перестали падать. Так я понял, что в прошлом команда врала себе, что всё в порядке. Пока в прод не ушёл баг, который проявлялся только во время пиковой нагрузки, а мой @Retry успешно его маскировал. @Retry — это как пластырь на ампутацию. Он не решает проблему, а лишь скрывает её, давая ложное ощущение безопасности.

Боль: Полагаться только на UI-тесты, чтобы проверить бэкенд, — это как пытаться узнать погоду, глядя в окно соседа. Ты видишь только то, что он хочет показать. Что происходит внутри его дома, ты не знаешь. А если он задёрнет шторы, ты останешься в полной темноте.

Best practice: Не верь, проверяй

Мы перестали доверять только UI. Мы начали писать тесты, которые напрямую проверяли Kafka и базу данных. Это позволило нам не гадать, а точно знать, что происходит.

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

// Мы получаем ясную картину событий

// Arrange: Создание заказа через API для скорости
@Test
void orderStatusIsUpdatedAfterPayment() {
    // Создаём заказ через быстрый API
    String orderId = orderApi.create(product, user);

    // Act: Ждём, пока сообщение будет отправлено в Kafka и обработано
    // Используем Awaitility, чтобы опрашивать систему, пока не получим нужный результат
    await().atMost(Duration.ofSeconds(10))
           .until(() -> dbHelper.getNewStatus(orderId).equals("COMPLETED"));

    // Assert: Проверяем, что в БД обновился статус
    String finalStatus = dbHelper.getOrderStatus(orderId);
    assertEquals("COMPLETED", finalStatus);
}

Эффект: Мы снизили flaky-rate до 5% и выявили 3 скрытых бага на бэкенде, которые никогда не проявились бы на UI-тестах. Мы получили полную картину происходящего. У нас была карта сокровищ — логи, метрики и чёткий отчёт Allure.

Акт IV: Инфраструктура CI/CD. Изоляция и параллельное выполнение

Проблема: "У меня локально всё работает, а на CI нет". Причины: разные версии баз данных, Redis, Kafka, или просто "грязная" среда. Эта проблема называется зависимостью от окружения.

Anti-pattern: Зависимость от внешних сервисов

Пайплайн падает, потому что "тестовый" Kafka-сервер недоступен, у него закончилось место на диске или его перезапустили с другими настройками. Твой тест, который локально работает с Postgres 14, падает на CI, потому что там стоит Postgres 12. Это хаос, который ты не можешь контролировать.

Из жизни: На прошлом проекте я узнал, что это называется «зависимостью от окружения». Однажды наш пайплайн упал, потому что на staging-сервере Redis переполнился. Логи были полны ошибок OutOfMemoryException. Мы переключились на Testcontainers, и с тех пор каждый тест запускается с чистым Redis-контейнером, полностью изолированным от проблем CI.

Best practice: Docker и Testcontainers — фундамент надёжности

Мы решили эту проблему радикально: мы начали поднимать все необходимые сервисы (базы данных, брокеры сообщений) внутри самого теста. Для этого мы использовали Docker и библиотеку Testcontainers (ссылка на docs.testcontainers.org).

Что это дало?

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

  • Предсказуемость: Тесты работают одинаково как на локальной машине, так и на CI. Это устраняет проблему "у меня работает".

Вот как выглядит простой пример использования Testcontainers с PostgreSQL:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
class OrderServiceTest {

    // Testcontainers автоматически скачает, запустит и остановит контейнер
    @Container
    private static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:14.5");

    @Test
    void testOrderCreationSavesToDatabase() {
        // Arrange: Настраиваем сервис с параметрами из контейнера
        OrderService orderService = new OrderService(postgresContainer.getJdbcUrl(), postgresContainer.getUsername(), postgresContainer.getPassword());

        // Act: Создаем заказ
        String orderId = orderService.createOrder("book");

        // Assert: Проверяем, что запись появилась в "чистой" базе данных
        String status = orderService.getOrderStatus(orderId);
        assertEquals("CREATED", status);
    }
}

Параллельное выполнение: Когда тесты стали полностью изолированными, мы получили возможность запускать их в несколько потоков. Настройка JUnit 5 для параллельного выполнения — это ещё один шаг к победе над хаосом. Мы сократили время полного регрессионного прогона с 5 часов до 45 минут.

Акт V: Advanced Practices. Оружие инженера QA

Best practice: Contract Testing — Тестирование контрактов

Проблема: Как убедиться, что твой микросервис "правильно" общается с другим микросервисом? Традиционные интеграционные тесты требуют развертывания всего окружения, а это долго, дорого и нестабильно.

Решение: Contract Testing. Это подход, который гарантирует, что API, которое предоставляет один сервис (Provider), соответствует ожиданиям другого сервиса (Consumer). Мы создаем "контракт" — небольшой документ, который описывает, какие данные и в каком формате ожидает Consumer.

Это позволяет тестировать взаимодействие между сервисами без их фактического развертывания. Provider просто проверяет, соответствует ли его API контракту. Consumer делает то же самое. Если контракты совпадают, мы можем быть уверены, что сервисы будут работать вместе.

Мы использовали Spring Cloud Contract, и это сэкономило нам огромное количество времени и ресурсов.

Best practice: Feature Toggles — Переключатели фич

Нам нужно было тестировать фичи, которые ещё не были готовы к выкату на прод. Закомментировать код? Создавать отдельные ветки? Это хаос.

Решение: Feature Toggles. Это простые флаги в конфигурации, которые позволяют включать или отключать функционал на лету. Мы могли включать новую фичу только для нашей тестовой среды, не боясь, что она попадёт на прод. Это также позволило нам обернуть в toggle тесты, которые были нестабильны в CI из-за проблем с окружением, что дало нам время на расследование.

Акт VI: Метрики и дашборды. Как приручить дракона

Проблема: "Мы не можем улучшить то, что не можем измерить". Без данных мы просто пожарные, тушащие огонь.

Best practice: Управление хаосом с помощью данных

Я создал таблицу "до/после", которая показала, что наши усилия не были напрасными. Мы начали измерять всё, что могли, и эти цифры стали нашим компасом.

Метрика

До внедрения

После внедрения

Улучшение

Flaky-rate

30%

<3%

90%

Время регрессии

5 часов

45 минут

85%

MTTR (среднее время на починку)

1 час

10 минут

83%

Code Coverage (покрытие кода)

<10%

65% (для API)

+55%

MTTR (по типу теста)

Не отслеживалось

UI: 25 мин, API: 5 мин

Улучшено

Наш Allure-дашборд показал, что 70% нестабильных тестов приходилось на микросервис "Уведомления". Это позволило нам сосредоточить усилия и быстро найти корень проблемы.

Allure Reports: Глаза и уши тестировщика

Allure Reports — это не просто отчет, это карта. Он превращает "сухие" логи в наглядную историю прохождения тестов. Мы видим:

  • Историю каждого теста.

  • Скриншоты и видео падений.

  • Время выполнения и причины ошибок.

  • Связь с Jira.

Это позволило нам локализовывать баги в один клик.

Эпилог: Манифест QA

Эта битва научила меня главному. Автоматизация — это не про инструменты, а про процесс и мышление. Хорошо организованные автотесты — это не просто код. Это фундамент для быстрой и уверенной поставки продукта. Это инвестиция, которая спасает время, нервы и репутацию всей команды.

Наш flaky-rate сейчас ниже 3%. Время регрессии сократилось с 5 часов до 45 минут. Это не магия. Это просто война с хаосом, в которой мы победили.

И помни: когда ты видишь красную полосу на дашборде, это не баг. Это просто очередной бой. И теперь у тебя есть все инструменты, чтобы победить.

Чек-лист для выживания в аду CI:

  • Тест атомарный = тест предсказуемый. Один тест — одна история. Никакого монстра.

  • Изоляция данных. Используй @BeforeEach/@AfterEach или Testcontainers, чтобы тесты не мешали друг другу.

  • Page Object = щит от хаоса UI. Используй стабильные локаторы и явные ожидания.

  • Flaky-тесты = не враг, а сигнал. Изолируй их и расследуй, не скрывай.

  • Метрики = карта битвы. Измеряй всё, чтобы принимать решения.

  • @Retry = временная мера, не решение. Ищи корень проблемы, а не маскируй её.

  • Логи + Allure = глаза и уши тестировщика. Локализация бага в один клик.

  • Testcontainers = надежность окружения. Твои тесты будут работать везде одинаково.

  • Contract Testing. Убедись, что твои сервисы говорят на одном языке.

  • Feature Toggles. Управляй сложным функционалом без лишних веток и комментирования кода.

Комментарии (3)


  1. MisterKot
    25.09.2025 10:55

    Хм. Сначала написано про WebDriverWait, а затем ниже про Selenide. Вы уж определитесь, голый Selenium, который у вас в примерах, или же Selenide, хотя последний не имеет вообще примеров.

    И из всего текста торчат уши ллм. Все сравнения, выделения, обороты, примеры. "Настоящесть" статьи под большим вопросом. Например:
    Настройка TestNG или JUnit 5 для параллельного выполнения — это ещё один шаг к победе над хаосом.
    Внезапно появился TestNG, хотя до этого речь была про JUnit5. Как будто промпт "напиши статью про автоматизацию на джаве, где я настроил процессы".


  1. Archie_L
    25.09.2025 10:55

    Спасибо за интересный материал. У меня вопрос по I акту, как вы разбивали "тест-монстр". Правильно ли я понимаю, что у вас был большой тест с кучей шагов, а вы тест переделали в тест-класс, в котором каждый тест-метод это какбы отдельный шаг того большого теста что вы разбиваете?


    1. makurea Автор
      25.09.2025 10:55

      Спасибо за интерес к статье и хороший вопрос!
      Да, вы верно поняли. Мы взяли один большой тет и разбили его на маленькие. Что бы один тест отвечал только за одну задачу. А так же был независим от других тестов. То-есть это скорее отдельные законченные проверки,а не шаги подряд.