ФАЗА 20. КАЧЕСТВО Стратегия тестов

Обязательно

Пирамида: много быстрых доказательств, мало сквозных

Unit · Integration · E2E

Каждый уровень проверяет свой риск и не дублирует остальные ради процента покрытия.

1. Unit проверяет правило в изоляции

1 / 5

Чистая функция или сервис с fake зависимостями выполняется за миллисекунды. Границы и ошибки получают отдельные случаи.

  • Нет сети, базы и общего контекста.
  • Тест объясняет дано, действие и результат.
  • Детали реализации не фиксируются без необходимости.
Unit: domain rules + boundariesmany, fast, isolatedFake clockFake repository

2. Integration проверяет стык с инфраструктурой

2 / 5

SQL, миграции, HTTP-клиент и сериализация требуют реального адаптера. Внешняя система поднимается локально или эмулируется по протоколу.

  • PostgreSQL не заменяется несовместимой имитацией.
  • Каждый тест изолирует данные.
  • Доменная логика не повторяется на этом уровне.
Integration: SQL + migrations + protocolReal adapterTestcontainersContract check

3. E2E подтверждает критичный пользовательский путь

3 / 5

Небольшой набор проходит систему целиком от интерфейса до данных. Он дорогой и хрупкий, поэтому резервируется для главных сценариев.

  • Smoke проверяет запуск и конфигурацию.
  • Один сценарий имеет понятный бизнес-результат.
  • Скриншот и trace упрощают разбор отказа.
UIAPIDomainDataОдин критичный путь, не все комбинации

4. Покрытие не равно уверенности

4 / 5

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

  • Порог coverage сопровождает, но не заменяет дизайн.
  • Дубли одного сценария удаляются.
  • Flaky тест чинится или изолируется с владельцем.
90% coveragelines executednot assertionsMutationchange > to >=Killed: goodSurvived: gap

5. Набор собирается по риску и скорости

5 / 5

Новая ошибка получает тест на самом низком уровне, который её доказывает. В CI быстрые проверки идут раньше контейнеров и E2E.

  • Тестовые данные создаются через builders.
  • Позитивные и негативные случаи различимы.
  • Регрессия становится постоянной проверкой.
Bug foundRule -> unitSQL -> integrationUser path -> E2ELowest levelthat proves it

Пирамида тестов: что и где

← назад к разделу

Чем больше кода, тем труднее понять, каких тестов писать больше, каких меньше и где проходит граница между уровнями. Пирамида тестов — простая модель, которая расставляет всё по местам.

пирамида: много быстрых внизу перевёрнутая: много медленных наверху ширина уровня = сколько тестов число × время одного = прогон уровня e2e12 тестовunit800 тестов e2e800 тестовunit12 тестов integration 120 тестов 120 × 2 с = 4 мин ловит поломку в любом месте пути ловит поломку в связке классов ловит поломку в одном классе 12 × 40 с = 8 мин800 × 5 мс = 4 скрасный тест сразу показывает, какой класс сломан 800 × 40 с = 8,9 часа12 × 5 мс = 0,06 скрасный тест говорит лишь «что-то на пути сломано» полный прогон в один поток, шкала — рабочий день 8 часов 0 4 ч 8 ч 12 минут≈ 12 минут — помещается в каждый коммит ≈ 9 часов — прогон не влезает в рабочий день

Ширина уровня — сколько тестов, справа — во что обходится один. Чем выше уровень, тем больше один тест ловит и тем дороже стоит. Перевёрнутая пирамида проверяет не больше: те же правила, но прогон из минут превращается в часы, а падение перестаёт показывать, какой класс сломан.

Зачем нужна пирамида

Без модели команды часто пишут либо только unit-тесты (не замечают проблем интеграции), либо только e2e-тесты (медленная обратная связь, хрупкие сьюты). Оба крайних случая дороги в поддержке.

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

Пирамида — модель, а не закон

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

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

Кубок (testing trophy). Если сервис — это в основном приём запроса, проверка, запрос в базу и ответ, то модульных тестов честно нечего писать: в классах нет ветвлений, вся суть в SQL и в маппинге. Попытка набрать «много unit» даёт тесты на заглушках, которые проверяют, что мок вызвали, — они зелёные всегда и не ловят ничего. Правильная форма здесь другая: широкий слой интеграционных тестов посередине, немного модульных на те места, где есть настоящие правила, и немного сквозных. Такую форму называют кубком, и для типичного сервиса работы с данными она честнее пирамиды.

Песочные часы. Много модульных, мало интеграционных, много сквозных. Обычно это не выбор, а симптом: интеграционные тесты писать неудобно (нет контейнеров, база поднимается вручную), и команда закрывает дыру сквозными. Лечится не переписыванием тестов, а тем, что делает интеграционные дешёвыми, — об этом статья про интеграционные тесты.

Перевёрнутая пирамида (много сквозных, мало модульных) — почти всегда беда: прогон в часы, мигающие тесты, никто не знает, что именно сломалось. Единственное оправдание — сервис, у которого своей логики нет вовсе, а есть только склейка чужих вызовов.

Что остаётся верным при любой форме и что стоит унести вместо картинки:

  • обратная связь по самой частой ошибке измеряется секундами, а не минутами;
  • дорогой тест пишут тогда, когда дешёвый не может проверить то же самое, — а не «для надёжности» вдобавок;
  • один сценарий проверяется на одном уровне: дублирование одного правила в модульном, интеграционном и сквозном тесте означает три красных теста на одну правку.

Что на пирамиде не лежит вообще. Пирамида — про функциональные тесты, и попытка разместить на ней всё остальное сбивает с толку. Рядом с ней живут отдельные оси: нагрузочные тесты (отвечают не «работает ли», а «сколько держит», требуют отдельного стенда и гоняются по расписанию, а не на коммит), контрактные тесты (проверяют договор между двумя сервисами, а не поведение одного), тесты миграций, проверки безопасности и сканеры зависимостей. У каждой из этих осей своя частота и своё место в конвейере, и ни одна не является «уровнем» пирамиды.

Три слоя

УровеньЧто проверяетСкорость
Unitодин класс / функция, без внешних зависимостеймиллисекунды
Integrationнесколько компонентов + реальная инфраструктурасекунды на тест (контейнер стартует один раз на прогон)
E2eполный путь от UI / API до БДдесятки секунд

Unit-тесты: изоляция и скорость

Unit-тест проверяет один класс в полной изоляции — без контекста фреймворка, без базы, без сети. Зависимости заменяются заглушками.

Слово «заглушка» здесь собирательное, и за ним прячутся пять разных вещей, которые стоит различать с самого начала — иначе разговор в команде превращается в спор о словах. Пустышка (dummy) передаётся только чтобы заполнить аргумент и никогда не вызывается. Стаб (stub) отвечает заранее заданным значением на заданный вопрос: «курс равен 90». Фейк (fake) — работающая упрощённая реализация: репозиторий на HashMap вместо базы. Шпион (spy) запоминает, как его вызывали, и позволяет это проверить после. Мок (mock) — заглушка с заранее заданными ожиданиями: тест падает, если вызов был не таким, как условлено. Разница между стабом и моком практическая: стаб проверяет результат («посчитало верно»), мок проверяет факт вызова («письмо отправлено»), и путаница между ними даёт тесты, которые ломаются при любом изменении кода. Подробный разбор с примерами и границами — в статье про моки и внешние системы.

Короткая формула: один тест — один сценарий поведения. Тест на пять сценариев подводит дважды: первый упавший assert прячет остальные, и по имени теста уже не понять, что именно сломалось.

class DiscountServiceTest {

    private final DiscountService service = new DiscountService();

    @Test
    void appliesDiscountWhenTotalExceedsThreshold() {
        var total = service.apply(new Money(1000), CustomerTier.GOLD);
        assertThat(total).isEqualTo(new Money(900));
    }

    @Test
    void noDiscountBelowThreshold() {
        var total = service.apply(new Money(500), CustomerTier.GOLD);
        assertThat(total).isEqualTo(new Money(500));
    }
}
func TestAppliesDiscountWhenTotalExceedsThreshold(t *testing.T) {
	service := NewDiscountService()
	total := service.Apply(Money(1000), TierGold)
	if total != Money(900) {
		t.Fatalf("ожидали 900, получили %d", total)
	}
}

func TestNoDiscountBelowThreshold(t *testing.T) {
	service := NewDiscountService()
	total := service.Apply(Money(500), TierGold)
	if total != Money(500) {
		t.Fatalf("ожидали 500, получили %d", total)
	}
}
describe('DiscountService', () => {
  const service = new DiscountService();

  it('applies discount when total exceeds threshold', () => {
    expect(service.apply(new Money(1000), CustomerTier.GOLD)).toEqual(new Money(900));
  });

  it('no discount below threshold', () => {
    expect(service.apply(new Money(500), CustomerTier.GOLD)).toEqual(new Money(500));
  });
});
def test_applies_discount_when_total_exceeds_threshold():
    service = DiscountService()
    assert service.apply(Money(1000), CustomerTier.GOLD) == Money(900)


def test_no_discount_below_threshold():
    service = DiscountService()
    assert service.apply(Money(500), CustomerTier.GOLD) == Money(500)

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

Integration-тесты: реальная инфраструктура

Integration-тест поднимает часть приложения (или всё целиком) и работает с реальными зависимостями — базой данных, брокером сообщений, внешним HTTP-сервисом.

База в примере ниже — не встроенная заглушка, а настоящий PostgreSQL: контейнер с ним поднимают один раз на весь набор тестов, чтобы не платить стартом в каждом.

@SpringBootTest
@Transactional
class OrderRepositoryIT extends PostgresTestBase {

    @Autowired
    OrderRepository repository;

    @Autowired
    EntityManager em;

    @Test
    void savesAndFindsOrder() {
        var order = repository.save(new Order(CustomerId.of("c-1"), List.of()));

        em.flush();
        em.clear();

        assertThat(repository.findById(order.id())).isPresent();
    }
}

flush() и clear() в середине — не украшение, а разница между настоящей проверкой и самообманом. Без них save только кладёт объект в контекст персистентности, а findById достаёт его же оттуда: ни INSERT, ни SELECT до базы не доходят, и тест остаётся зелёным даже при перепутанных колонках и неверном имени таблицы. flush() отправляет запрос в базу, clear() очищает кеш — дальше поиск действительно читает то, что записалось.

func TestSavesAndFindsOrder(t *testing.T) {
	pool := postgresPool(t)   // контейнер поднят один раз в TestMain
	repo := NewOrderRepository(pool)

	order, err := repo.Save(context.Background(), NewOrder(CustomerID("c-1"), nil))
	if err != nil {
		t.Fatal(err)
	}

	found, err := repo.FindByID(context.Background(), order.ID)
	if err != nil || found == nil {
		t.Fatalf("заказ не найден после сохранения: %v", err)
	}
}
describe('OrderRepository (postgres)', () => {
  let repository: OrderRepository;

  beforeAll(async () => {
    repository = new OrderRepository(await postgresPool());   // контейнер поднят один раз в globalSetup
  });

  it('saves and finds order', async () => {
    const order = await repository.save(new Order(CustomerId.of('c-1'), []));
    await expect(repository.findById(order.id)).resolves.toBeDefined();
  });
});
async def test_saves_and_finds_order(postgres_pool):   # фикстура поднимает контейнер один раз на сессию
    repository = OrderRepository(postgres_pool)

    order = await repository.save(Order(CustomerId("c-1"), []))

    assert await repository.find_by_id(order.id) is not None

Реальную базу в тестах поднимает Testcontainers — PostgreSQL (или другой движок) в Docker-контейнере, который останавливается после сьюта. Конфигурация Spring-тестов подробнее описана в разделе Тестирование в Spring, а сам контейнер — в интеграционных тестах.

Integration-тесты медленнее unit, поэтому их пишут на критические пути: репозитории, обработчики команд, HTTP-клиенты к внешним сервисам.

E2e-тесты: полный путь запроса

E2e-тест проходит весь путь: HTTP-запрос → контроллер → бизнес-логика → база → HTTP-ответ. Из всего, что запускается в сборке, это самое близкое к продакшену: настоящий веб-сервер, настоящий порт, настоящая сериализация ответа.

@SpringBootTest(webEnvironment = RANDOM_PORT)
class CreateOrderE2eTest {

    @Autowired
    TestRestTemplate http;

    @Test
    void createsOrderAndReturns201() {
        var body = new CreateOrderRequest(customerId, items);
        var response = http.postForEntity("/orders", body, Void.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}
func TestCreatesOrderAndReturns201(t *testing.T) {
	srv := httptest.NewServer(app.Router())   // настоящее приложение на случайном порте
	defer srv.Close()

	resp, err := http.Post(srv.URL+"/orders", "application/json", strings.NewReader(`{"customerId":"c-1","items":["item-a"]}`))
	if err != nil {
		t.Fatal(err)
	}
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("ожидали 201, получили %d", resp.StatusCode)
	}
}
describe('POST /orders (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    await app.init();
  });

  it('creates order and returns 201', async () => {
    await request(app.getHttpServer()).post('/orders').send({ customerId, items }).expect(201);
  });
});
async def test_creates_order_and_returns_201(client: AsyncClient):   # httpx поверх приложения FastAPI
    response = await client.post("/orders", json={"customerId": customer_id, "items": items})

    assert response.status_code == 201

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

E2e-тесты — самые дорогие: поднимают весь контекст, медленно запускаются, сложно локализовать падение. Их пишут немного — только на ключевые пользовательские сценарии.

Почему наверху мало: мигающие тесты

У пирамиды есть вторая причина сужаться, и она важнее времени прогона. Чем выше уровень, тем чаще тест мигает: то зелёный, то красный при том же самом коде.

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

Откуда конкретно берётся мигание:

  • Пауза вместо ожидания. Thread.sleep(500) работает на машине разработчика и не работает на загруженном агенте сборки. Заменяется ожиданием условия с таймаутом (Awaitility): «ждать, пока в базе не появится запись, но не дольше пяти секунд».
  • Общее состояние. Тесты используют одну базу и одни и те же записи: один тест дописывает строку, второй считает и получает не то число. Лечится уникальными данными на каждый тест и очисткой за собой.
  • Порядок выполнения. Тест проходит в одиночку и падает в прогоне, потому что зависит от того, что оставил сосед. Проверяется просто: запустить класс тестов в случайном порядке.
  • Время и часовые пояса. Тест, зелёный днём и красный в полночь, — классика; лечится подменой источника времени, а не выбором «безопасных» дат.
  • Параллельный прогон. Тесты, которые по отдельности честны, вместе конкурируют за порт, за файл, за одну и ту же строку в базе.

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

Разбор каждой причины с кодом — в статье про интеграционные тесты.

Что тестировать, а что нет

Стоит тестировать:

  • бизнес-правила и граничные случаи (скидки, лимиты, статусные переходы)
  • маппинг данных между слоями
  • обработку ошибочных входных данных
  • взаимодействие с базой на критических путях

Не стоит тестировать:

  • геттеры, сеттеры, конструкторы (очевидный код без логики)
  • конфигурацию фреймворка — он уже протестирован сам по себе
  • детали реализации, которые могут поменяться (внутренние приватные методы)

Правило: тест должен сломаться, когда поведение меняется, и оставаться зелёным при рефакторинге без изменения поведения.

Из этого правила выводятся оба края, про которые обычно спорят.

Когда модульный тест бессмысленен. Класс без ветвлений — единственный путь исполнения, проверять нечего, кроме того, что компилятор уже проверил. Тонкая обёртка над репозиторием, вся суть которой в одном вызове: модульный тест на ней проверяет, что мок вызвали, то есть проверяет сам себя, а настоящий вопрос («правильный ли запрос и правильно ли он читает данные») решается только интеграционным тестом. Класс, состоящий из вызовов чужого кода: тест на нём фиксирует вашу фантазию об этом чужом коде. Признак такого теста один: чтобы он упал, нужно изменить сам тест.

Когда сквозной тест оправдан несмотря на цену. Там, где отказ стоит денег или доверия, а путь проходит через несколько систем: оплата от нажатия до подтверждения от провайдера, регистрация с письмом и первым входом, оформление заказа со списанием остатков. Здесь важно не поведение одного класса, а то, что все звенья соединены: конфигурация, права, маршруты, соседи. Ни один нижний уровень этого не проверяет — поэтому один сквозной тест на каждый такой путь пишут и терпят его цену. Ключевое слово «один»: второй сквозной тест на тот же путь с другими данными почти наверняка уже лишний.

Дополнительно: при первом чтении можно пропустить

Глубже: как пишется сам тест: имя, дано-действие-проверка, читаемые проверкирасширенное

Пирамида отвечает, какие тесты и сколько, и не отвечает, как выглядит один тест. А первый рабочий день начинается именно с него.

Имя говорит о поведении, а не о методе: не testCancel, а cancels paid order and refunds payment через @DisplayName или имя метода cancel_paidOrder_refundsPayment. По списку имён упавших тестов должно быть понятно, что сломалось, без чтения кода.

Три части, видимые глазом. Дано: подготовка состояния, объекты, заглушки. Действие: один вызов проверяемого. Проверка: утверждения о результате. Части разделены пустой строкой, и каждая занимает несколько строк, а не экран; подготовка на тридцать строк это сигнал, что нужны билдеры, о которых следующий раздел.

Одно утверждение на смысл. Не одно assert на тест, а одна проверяемая мысль: «заказ отменён и деньги возвращены» это два утверждения об одном поведении в одном тесте, а «отменяется и не отменяется дважды» это два теста. Библиотека утверждений (AssertJ, testify, expect в jest, голый assert в pytest) читается как фраза и объясняет падение сам:

assertThat(order.status()).as("статус после отмены").isEqualTo(CANCELLED);
assertThat(refunds).extracting(Refund::amount).containsExactly(order.total());
assertThatThrownBy(() -> service.cancel(orderId))
    .isInstanceOf(OrderAlreadyCancelledException.class);
assert.Equal(t, Cancelled, order.Status(), "статус после отмены")
assert.Equal(t, []Money{order.Total()}, amounts(refunds))
assert.ErrorAs(t, service.Cancel(orderID), new(*OrderAlreadyCancelledError))
expect(order.status).toBe(OrderStatus.CANCELLED);   // статус после отмены
expect(refunds.map((r) => r.amount)).toEqual([order.total]);
await expect(service.cancel(orderId)).rejects.toBeInstanceOf(OrderAlreadyCancelledError);
assert order.status is OrderStatus.CANCELLED, "статус после отмены"
assert [r.amount for r in refunds] == [order.total]
with pytest.raises(OrderAlreadyCancelled):
    service.cancel(order_id)

В AssertJ extracting, containsExactly, usingRecursiveComparison для сравнения объектов по полям и as для подписи избавляют от assertTrue(list.size() == 1 && ...), по падению которого не видно ничего. assertAll группирует несколько утверждений, чтобы увидеть все падения сразу, а не первое.

Тест обязан уметь падать. Тест, который зелёный при любом коде, бесполезен и вреден, потому что создаёт уверенность. Проверяют это один раз при написании: сломать проверяемое место (закомментировать возврат денег) и увидеть красный; так же ловят тесты без утверждений и утверждения на заглушках вместо результата. Мутационное тестирование делает это за вас по всему проекту, о чём раздел про покрытие.

Порядок написания. Тест можно писать до кода, и у этого порядка есть имя: разработка через тестирование, TDD. Цикл короткий. Красный тест, который описывает одно поведение и падает, потому что кода ещё нет; минимальный код, чтобы он стал зелёным; уборка кода при зелёных тестах. Выигрыш не в покрытии, а в том, что тест написан до того, как вы знаете реализацию, и поэтому проверяет поведение, а не повторяет код; а то, что тест умеет падать, доказано на первом же шаге. Где это ломается: исследовательский код, когда вы ещё не знаете, какой интерфейс нужен, и тонкая обвязка над фреймворком, где тест дороже кода. Место практики среди остальных разобрано в статье про экстремальное программирование.

Глубже: тестовые данные: билдеры, Object Mother и генераторырасширенное

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

Билдер с умолчаниями. Класс, который собирает валидный объект с разумными значениями по умолчанию и позволяет переопределить только то, что важно для теста: anOrder().withStatus(PAID).withItems(item("SKU-1", 2)).build(). Тест показывает лишь значимые поля, остальное скрыто, новое поле в объекте добавляется в билдер один раз. Lombok @Builder с @Builder.Default даёт это почти бесплатно для DTO, для доменных объектов билдер пишут руками, чтобы он проходил через настоящие правила создания.

Object Mother. Именованные готовые сценарии поверх билдеров: Orders.paidOrder(), Orders.orderAwaitingPayment(), Customers.vipCustomer(). Имя говорит, какой случай тестируется, и один и тот же сценарий переиспользуют десятки тестов. Ловушка: общая изменяемая фикстура, которую один тест правит, а другой читает; поэтому каждый вызов возвращает новый объект.

Генераторы. Instancio и подобные заполняют объект случайными валидными значениями по типам и ограничениям: тест, которому нужен «какой-то покупатель», не задаёт ни одного поля, а тест, которому важен email, задаёт только его. Зерно случайности печатается при падении, чтобы воспроизвести. Хорошо для больших DTO и для поиска ошибок на неожиданных значениях, плохо там, где значение важно для смысла: там пишут явно.

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

Глубже: хватает ли тестов: покрытие JaCoCo и мутационное тестированиерасширенное

«Сколько тестов» отвечает пирамида, «хватает ли» не отвечает никто. Два инструмента дают два разных ответа.

Покрытие. Инструмент зависит от стека: JaCoCo в Java, go test -coverprofile=cover.out с go tool cover в Go, coverage.py (pytest --cov) в Python, c8/istanbul (jest --coverage) в Node. Все делают одно и то же: JaCoCo записывает, какие строки и ветки выполнились во время тестов, и рисует отчёт: 78 % строк, 61 % ветвлений. Полезно ровно в одном смысле: 0 % у модуля означает, что его не тестируют вовсе, и это стоит знать. Всё остальное покрытие не говорит: строка выполнена не значит проверена, тест без утверждений даёт 100 %, а самая важная ветка «если баланса не хватает» может быть теми 22 %, что не покрыты. Порог в сборке (jacocoTestCoverageVerification с минимумом) защищает от падения покрытия при новом коде и одновременно провоцирует тесты ради процента; ставят его невысоким и по модулям, а не одним числом на проект, и смотрят на непокрытое глазами, а не на процент.

Мутационное тестирование. PIT меняет код по одному месту (условие на противоположное, + на -, убирает вызов) и запускает тесты: если ни один не упал, мутант выжил, и это место не проверено, какое бы покрытие ни было. Доля убитых мутантов честнее процента строк, и отчёт показывает конкретные выжившие мутации, каждая из которых это либо недостающий тест, либо мёртвый код. Цена: прогон в десятки раз дольше тестов, поэтому его гоняют по ядру домена ночью или на изменённых классах в PR, а не по всему проекту на каждый коммит. Аналоги PIT: go-mutesting и gremlins в Go, mutmut в Python, Stryker в Node и TypeScript.

Ответ на вопрос «хватает ли». Не число, а три проверки: каждое бизнес-правило названо тестом с понятным именем; мутанты в ядре домена не выживают; последний инцидент воспроизводится тестом, который был бы красным до исправления. Проект, где эти три выполнены при 60 % покрытия, защищён лучше, чем проект с 95 % и тестами на геттеры.

Глубже: тесты в сборке: что на коммит, что на PR, что по расписаниюрасширенное

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

На каждый коммит в ветке: модульные тесты и быстрые проверки (компиляция, линтер), минуты. Их запускает и разработчик локально перед отправкой. На pull request: интеграционные тесты с контейнерами, срезы, контрактные тесты своей стороны; вместе с модульными это бюджет в десять минут, и он защищён: тест, который его ломает, оптимизируют или переносят выше. На слияние в main: то же плюс публикация артефакта и выкат на тестовый контур. По расписанию или на кандидата выпуска: сквозные тесты на препроде, нагрузочные, мутационные, тесты миграций на снимке прода; часы, и падение здесь это не блок слияния, а задача на утро.

Технически это разделение задач сборки (test и integrationTest в Gradle с метками JUnit @Tag, флаг -short и build-теги в Go, маркеры pytest, отдельные проекты jest) и разные события в файле конвейера, о чём статья про CI для Java. Общее правило: чем дороже тест, тем реже он идёт и тем дальше от коммита; и у каждой ступени есть владелец времени, который видит, что прогон вырос с шести минут до пятнадцати, раньше, чем команда привыкнет ждать.

Глубже: техники тест-дизайна: классы эквивалентности, граничные значения, позитивные и негативные тестырасширенное

Какие именно значения подать в тест, чтобы не перебирать всё подряд, решают техники, пришедшие из ручного тестирования, и спрашивают их не только у тестировщиков. Классы эквивалентности делят все возможные входы на группы, внутри которых код ведёт себя одинаково: для поля «количество от 1 до 100» это три класса, меньше единицы, от 1 до 100 и больше ста, и достаточно одного значения из каждого. Граничные значения берут края классов, где ошибки «на единицу» живут чаще всего: 0, 1, 100, 101. Вместе это даёт пять-шесть тестов вместо сотни и ловит больше, чем сто случайных.

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

Отдельно живут проверки, которые описывают не вход, а момент: дымовой тест после выката отвечает на вопрос «вообще работает?» несколькими ключевыми сценариями, регрессионный набор следит, что исправленное не сломалось снова, и в него добавляют тест на каждый инцидент.

Коротко

  • Пирамида: много unit → умеренно integration → мало e2e. Чем выше уровень, тем дороже запуск и тем меньше тестов нужно.
  • Unit-тесты — быстрые, изолированные, без контекста фреймворка; покрывают бизнес-логику.
  • Integration-тесты — с реальной инфраструктурой (Testcontainers, поднятое приложение); для репозиториев и HTTP-клиентов. E2e-тесты — полный путь запроса; пишут только на ключевые сценарии.
  • Тестируют поведение и бизнес-правила, не геттеры и не фреймворк.
  • Тест: имя о поведении, три части (дано, действие, проверка), одно утверждение на смысл, утверждения с подписями; тест обязан уметь падать, и это проверяют при написании.
  • Данные: билдеры с умолчаниями, Object Mother для именованных сценариев, генераторы с зерном для неважных полей, фикстуры под тест, а не общий data.sql.
  • «Хватает ли»: покрытие JaCoCo показывает лишь нули, мутационное тестирование PIT показывает непроверенные места; смотрят на имена правил, выживших мутантов в ядре и тест на последний инцидент.
  • В сборке: модульные на коммит, интеграционные и контрактные на PR в бюджете десяти минут, сквозные, нагрузочные и мутационные по расписанию.
  • Пирамида — модель цены, а не закон: при тонкой логике и толстом SQL форма честнее кубком; чем выше уровень, тем чаще тест мигает, а мигающий тест — дефект, приучающий игнорировать красное.
  • TDD и тест-дизайн: красный тест о поведении, минимальный код до зелёного, уборка; классы эквивалентности и граничные значения вместо перебора, на каждое правило позитивный тест и негативный на каждое нарушение.

Что почитать дальше