Пирамида тестов: что и где
Чем больше кода, тем труднее понять, каких тестов писать больше, каких меньше и где проходит граница между уровнями. Пирамида тестов — простая модель, которая расставляет всё по местам.
Ширина уровня — сколько тестов, справа — во что обходится один. Чем выше уровень, тем больше один тест ловит и тем дороже стоит. Перевёрнутая пирамида проверяет не больше: те же правила, но прогон из минут превращается в часы, а падение перестаёт показывать, какой класс сломан.
Зачем нужна пирамида
Без модели команды часто пишут либо только 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 и тест-дизайн: красный тест о поведении, минимальный код до зелёного, уборка; классы эквивалентности и граничные значения вместо перебора, на каждое правило позитивный тест и негативный на каждое нарушение.
Что почитать дальше
- Integration-тесты в деталях —
Testcontainers, транзакции, изоляция тестовых данных. - Моки и внешние зависимости — когда использовать
WireMock, а когда реальную заглушку. - Тестирование в Spring —
@WebMvcTest,@DataJpaTest, слайсы и конфигурация контекста. - Нагрузочное тестирование: отдельная ось рядом с пирамидой, отвечает не «работает ли», а «сколько держит».