Все уроки
БэкендСредний12 мин чтенияВ моём стеке

Тестирование

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

  • Unit
  • Feature
  • E2E
  • PHPUnit
  • Jest
  • Playwright
Шаг 1/6
Разработчик делает push. CI/CD pipeline просыпается, и первым делом linter проверяет стиль. Зелёный.

За 30 секунд

Тесты — это небольшие программы, которые проверяют, что ваш код делает то, что должен. Unit-тесты проверяют одну функцию, feature- или интеграционные тесты проверяют целый запрос на реальной базе данных, а end-to-end-тесты прокликивают настоящее приложение в браузере. Хороший набор тестов запускается на каждый pull request и ловит регрессии раньше, чем их заметят клиенты.

Почему я это использую

  • Я пишу тесты, потому что части, которые двигают деньги, остатки на складе и баллы лояльности, никогда не должны тихо ломаться.
  • Для API я опираюсь на feature-тесты, потому что они проверяют то, что реально вызывают клиенты, а не внутренние детали.
  • Я запускаю тесты в CI на каждый pull request, чтобы команда — и AI-агенты, которые работают вместе с нами, — получали быструю обратную связь.
  • Именно тесты делают рефакторинг большой кодовой базы возможным, а не пугающим.

Как это работает

01Пирамида тестирования

Представьте проверку автомобиля. Каждую деталь вы проверяете на стенде (unit-тесты): их много, они быстрые и дешёвые. Системы вы проверяете вместе — двигатель с коробкой передач (интеграционные и feature-тесты): их меньше, они медленнее. А потом выезжаете на всей машине (end-to-end-тесты): их мало, они самые медленные, но ближе всего к реальности. Пирамида говорит: много unit-тестов внизу, солидный слой интеграционных тестов и тонкий слой E2E наверху. Для бэкенд-API многие команды предпочитают форму «кубка» (trophy), где самый большой слой — feature-тесты, вызывающие настоящие эндпоинты с настоящей тестовой базой, потому что именно там живёт большинство багов. В любом случае: быстрые тесты запускаются постоянно, медленные покрывают только критичные сценарии.

  • Unit: одна функция или класс, без базы данных и сети.
  • Feature / интеграционные: на входе HTTP-запрос, проверяются база данных и ответ.
  • E2E: настоящий браузер или устройство проходит через реальный UI и API.

02PHPUnit и Pest для Laravel

Laravel поставляется с PHPUnit и Pest (более дружелюбным синтаксисом поверх PHPUnit). Feature-тесты отправляют HTTP-запросы в ваше приложение прямо в памяти и проверяют ответ, базу данных и побочные эффекты. Используйте трейт RefreshDatabase, чтобы каждый тест начинался с чистого листа; запускайте тесты на том же движке базы данных, что и в продакшене (MySQL или PostgreSQL), потому что SQLite в пограничных случаях ведёт себя иначе. Фабрики создают реалистичные модели одной строкой, со состояниями для вариаций вроде оплаченного заказа или заблокированного пользователя. Фейки Laravel подменяют внешние эффекты: Queue::fake, Mail::fake, Notification::fake, Event::fake и Http::fake позволяют проверить, что что-то было отправлено, ничего не отправляя на самом деле.

php
it('lets a customer place an order', function () {
    Queue::fake();
    $user = User::factory()->create();
    $product = Product::factory()->create(['price' => 2500]);
 
    $this->actingAs($user)
        ->postJson('/api/v1/orders', ['items' => [['product_id' => $product->id, 'qty' => 2]]])
        ->assertCreated()
        ->assertJsonPath('data.total', 5000);
 
    $this->assertDatabaseHas('orders', ['user_id' => $user->id, 'total' => 5000]);
    Queue::assertPushed(SendOrderConfirmation::class);
});

03Jest, Vitest и node:test для TypeScript

В мире JavaScript Vitest — современный выбор по умолчанию для проектов на Vite и большинства новых TypeScript-проектов: быстрый, ESM-нативный и совместимый с Jest. Jest по-прежнему распространён, особенно в React Native и старых кодовых базах. В Node есть и встроенный раннер node:test, вообще без зависимостей. В NestJS используйте testing module, чтобы собрать срез приложения и подменить провайдеры фейками; для API-тестов запросы в приложение отправляет supertest. Чистую логику (ценообразование, права, правила работы с датами) тестируйте множеством маленьких unit-тестов, а эндпоинты — на настоящем PostgreSQL в Docker или Testcontainers, а не через моки ORM.

ts
import { describe, it, expect } from 'vitest';
import { applyPromotion } from './pricing';
 
describe('applyPromotion', () => {
  it('never discounts below zero', () => {
    expect(applyPromotion({ subtotal: 1000 }, { type: 'fixed', amount: 1500 }).total).toBe(0);
  });
 
  it('applies percentage to subtotal only, not delivery', () => {
    const r = applyPromotion({ subtotal: 10_000, delivery: 500 }, { type: 'percent', amount: 10 });
    expect(r.total).toBe(9_500);
  });
});

04Playwright для end-to-end-тестов

Playwright управляет настоящими браузерами Chromium, Firefox и WebKit. Он автоматически ждёт появления элементов, поэтому тесты гораздо менее нестабильны, чем в старых инструментах, а при падении записывает трейсы со скриншотами и сетевыми логами. Пишите E2E-тесты для нескольких сценариев, которые приносят или теряют деньги: вход, добавление в корзину и оформление заказа, ключевое действие в админке. Выбирайте элементы по роли и видимому тексту — так, как их находят пользователи, — а не по CSS-классам. Готовьте нужные каждому тесту данные через API или фикстуру, а не прокликиванием экранов настройки, и запускайте набор на окружении, похожем на staging, с заглушками внешних сервисов.

ts
import { test, expect } from '@playwright/test';
 
test('guest can check out', async ({ page }) => {
  await page.goto('/products/sample-product');
  await page.getByRole('button', { name: 'Add to cart' }).click();
  await page.getByRole('link', { name: 'Cart' }).click();
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByLabel('Phone').fill('+10000000000');
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByRole('heading', { name: 'Thank you' })).toBeVisible();
});

05Моки внешних сервисов и фикстуры

Ваши тесты никогда не должны обращаться к настоящему платёжному шлюзу, SMS-провайдеру или ERP. Подменяйте их на границе: оберните каждую интеграцию в собственный класс-клиент, а в тестах замените его фейком или застабьте его HTTP-вызовы (Http::fake в Laravel, MSW или nock в Node). Записывайте реалистичные ответы, включая ошибки и таймауты, в фикстуры, чтобы тестировать и пути отказа. Не мокайте то, чем владеете сами, — свои сервисы или базу данных: избыток моков даёт тесты, которые проходят, пока продакшен ломается. Фабрики собирают тестовые данные с разумными значениями по умолчанию, так что каждый тест указывает только то, что важно именно для него. Держите тесты независимыми: никакого общего состояния и обязательного порядка запуска.

06Тестирование в CI и что тестировать в первую очередь

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

yaml
# .github/workflows/test.yml (excerpt)
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: app_test }
        ports: ["5432:5432"]
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck && pnpm lint && pnpm test

Когда применять

  • Любой код, который считает деньги, остатки, баллы или права доступа.
  • Перед рефакторингом, чтобы зафиксировать текущее поведение.
  • Для каждого исправления бага — как регрессионный тест.
  • Общие пакеты и API, которыми пользуются несколько приложений.

Когда не стоит

  • Одноразовые прототипы, код которых удалят на следующей неделе.
  • Тестирование внутренностей фреймворка или тривиальных геттеров только ради роста покрытия.
  • Огромные snapshot-тесты, которые никто не читает и обновляют вслепую.

Альтернативы

  • Статическая типизация и линтинг

    Strict-режим TypeScript и PHPStan ловят целый класс багов ещё до запуска тестов.

  • Контрактные тесты

    Проверяют, что продюсеры и потребители API согласны в формах данных, без полного E2E.

  • Ручное QA и исследовательское тестирование

    По-прежнему ценны для UX и пограничных случаев, которые автоматизация пропускает.

  • Мониторинг в продакшене

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

Лучшие практики

  1. Тестируйте поведение через публичные интерфейсы, а не приватные детали реализации.
  2. Для API предпочитайте feature-тесты с настоящей базой данных.
  3. Подменяйте внешние сервисы на границе; не мокайте собственный код.
  4. Используйте фабрики, чтобы каждый тест показывал только важные для него данные.
  5. Перед исправлением каждого бага пишите падающий тест.
  6. Запускайте тесты на каждый pull request и держите набор быстрым.
  7. E2E-тестов должно быть мало, и они должны фокусироваться на сценариях, критичных для выручки.

Частые ошибки

  • Гонка за 100% покрытия, когда у оформления заказа нет ни одного теста.
  • Моки базы данных или ORM, из-за которых тесты проходят, а реальные запросы падают.
  • Нестабильные тесты, зависящие от времени, порядка или сети, и команда, которая привыкает игнорировать красные сборки.
  • Тестирование на SQLite, когда в продакшене MySQL или PostgreSQL.
  • Настоящие API-ключи или вызовы живых платёжных провайдеров в тестах.

Глубже

07Быстрые изолированные тесты с базой данных

Тесты с базой данных — самые ценные тесты бэкенда и проще всего становятся медленными. Стратегии изоляции, от самой быстрой: оборачивать каждый тест в транзакцию и откатывать её (RefreshDatabase в Laravel делает это после одной миграции на запуск, а LazilyRefreshDatabase пропускает миграцию, если тест вообще не трогает базу); очищать таблицы между тестами, когда тестируемый код открывает свои транзакции или соединения; или пересоздавать базу, что медленнее всего. Запускайте параллельно: php artisan test --parallel даёт каждому процессу свою базу с суффиксом, так что тесты не сталкиваются. В Node Testcontainers поднимает настоящий PostgreSQL в Docker на каждый запуск тестов, поэтому CI и ноутбуки используют тот же движок и ту же версию, что и продакшен. Трюки для скорости: держите базу в CI на tmpfs, мигрируйте один раз и клонируйте из template database в PostgreSQL и никогда не используйте sleep в тестах.

ts
import { before, after } from 'node:test';
import { execSync } from 'node:child_process';
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from '@testcontainers/postgresql';
 
let pg: StartedPostgreSqlContainer;
 
before(async () => {
  pg = await new PostgreSqlContainer('postgres:17').start();
  process.env.DATABASE_URL = pg.getConnectionUri();
  execSync('npx prisma migrate deploy', { stdio: 'inherit' });
});
 
after(async () => {
  await pg.stop();
});

08За пределами примеров: property-based и мутационное тестирование

Тесты на примерах проверяют случаи, о которых вы подумали. Property-based тестирование проверяет правила на сотнях сгенерированных входных данных: вместо «скидка 10% на 1,000 даёт 900» вы утверждаете «для любой суммы и любого процента от 0 до 100 итог никогда не бывает отрицательным и никогда не превышает сумму». Библиотеки вроде fast-check для TypeScript генерируют входные данные и при падении сжимают случай до минимального падающего примера. Это особенно хорошо для ценообразования, округления, парсинга и логики дат. Мутационное тестирование измеряет, насколько хороши ваши тесты, а не сколько кода они выполняют. Инструменты вроде Infection для PHP и Stryker для JavaScript вносят в код маленькие изменения, например превращают >= в >, и перезапускают тесты. Если тесты всё ещё проходят, мутант выжил, а значит, не хватает проверки. Покрытие говорит, что строка выполнилась; mutation score говорит, что тест заметил бы, если бы она была неправильной.

ts
import fc from 'fast-check';
import { test, expect } from 'vitest';
import { applyPromotion } from './pricing';
 
test('percentage promotions stay within 0 and the subtotal', () => {
  fc.assert(
    fc.property(
      fc.integer({ min: 0, max: 10_000_000 }),
      fc.integer({ min: 0, max: 100 }),
      (subtotal, percent) => {
        const { total } = applyPromotion({ subtotal }, { type: 'percent', amount: percent });
        expect(total).toBeGreaterThanOrEqual(0);
        expect(total).toBeLessThanOrEqual(subtotal);
      },
    ),
  );
});

09Архитектурные и контрактные тесты

Некоторые правила касаются структуры, а не поведения, и тесты тоже могут их охранять. Архитектурное тестирование в Pest позволяет утверждать, что контроллеры никогда не используют фасад DB, что доменный слой никогда не импортирует HTTP-классы или что отладочные функции вроде dd() не попадают в продакшен; есть и пресеты для типовых наборов правил. Между приложениями и сервисами контрактные тесты ловят самый дорогой класс багов: одна сторона меняет поле, от которого зависит другая. С consumer-driven contracts (самый известный инструмент — Pact) потребитель записывает, чего он ждёт от провайдера, а CI провайдера проверяет, что тот по-прежнему удовлетворяет каждого потребителя. Более лёгкий вариант — валидировать реальные ответы API в feature-тестах по вашей схеме OpenAPI. Оба способа гораздо быстрее и точнее полных end-to-end наборов, когда нужно доказать, что две системы всё ещё согласованы.

php
// tests/ArchTest.php (Pest)
arch()->preset()->php();       // e.g. no dd(), dump() or var_dump()
arch()->preset()->security();  // flags eval(), md5(), sha1() and similar
 
arch('controllers do not query the database directly')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Support\Facades\DB');
 
arch('domain does not depend on HTTP')
    ->expect('App\Domain')
    ->not->toUse('Illuminate\Http');

10Тестирование времени, повторов и конкурентности

Баги, доходящие до продакшена, часто связаны со временем и повторением: подписка, истекающая на секунду раньше, задача, которая при повторе отправляет дважды, два запроса, продающие больше, чем есть на складе. Тестируйте их намеренно. Замораживайте или сдвигайте время вместо ожидания: travelTo() и travel() в Laravel, Carbon::setTestNow() или vi.useFakeTimers() и vi.setSystemTime() в Vitest. Тестируйте границы — секунду до и после дедлайна — и часовые пояса около полуночи и перехода на летнее время. Для идемпотентности запускайте одну и ту же задачу или webhook дважды и проверяйте, что эффект один. Для гонок юнит-тест с моками ничего не доказывает; отправляйте настоящие конкурентные запросы к настоящей базе, например через Promise.all в интеграционном тесте или коротким скриптом k6, и проверяйте, что инвариант — например, остаток никогда не ниже нуля — по-прежнему выполняется.

php
it('expires a subscription exactly at its end time', function () {
    $sub = Subscription::factory()->create(['ends_at' => '2026-03-01 00:00:00']);
 
    $this->travelTo('2026-02-28 23:59:59');
    $this->artisan('subscriptions:expire')->assertSuccessful();
    expect($sub->fresh()->status)->toBe(SubscriptionStatus::Active);
 
    $this->travelTo('2026-03-01 00:00:01');
    $this->artisan('subscriptions:expire')->assertSuccessful();
    expect($sub->fresh()->status)->toBe(SubscriptionStatus::Expired);
});
 
it('creates one invoice even if the job runs twice', function () {
    $order = Order::factory()->paid()->create();
    GenerateInvoice::dispatchSync($order->id);
    GenerateInvoice::dispatchSync($order->id);
    expect(Invoice::where('order_id', $order->id)->count())->toBe(1);
});

Реальные ситуации

01

CI-пайплайн идёт 35 минут

Проблема

Набор тестов бэкенда разросся до нескольких тысяч тестов. Pull requests ждут CI больше получаса, поэтому разработчики копят изменения и мержат реже.

Как решить

Сначала профилируйте: Pest и PHPUnit умеют показывать самые медленные тесты, и обычно доминируют несколько штук. Уберите sleep и реальные сетевые вызовы и добавьте Http::preventStrayRequests(), чтобы любой не подменённый HTTP-вызов громко падал. Перейдите на refresh через транзакции и ленивый refresh, запускайте с --parallel и разместите базу CI на tmpfs. Разбейте пайплайн на параллельные задачи для статического анализа, юнит- и feature-тестов и шардируйте большие наборы по машинам через шардирование Pest или Playwright. Кэшируйте зависимости Composer и pnpm. Полные E2E-прогоны перенесите в merge queue или на ночь, оставив на каждом pull request небольшой smoke-набор. Цель — меньше десяти минут.

02

85% покрытия, а баг в ценообразовании всё равно в продакшене

Проблема

Комбинация скидок на выходных давала отрицательные суммы. У модуля ценообразования было высокое покрытие, и все тесты были зелёными.

Как решить

Покрытие доказало, что код выполнялся, а не что результаты проверялись. Сначала напишите регрессионный тест на точную комбинацию. Затем добавьте property-based тесты для инвариантов ценообразования: суммы никогда не отрицательные, никогда не больше subtotal, а скидки никогда не складываются сверх правил. Запустите мутационное тестирование через Infection или Stryker на модуле ценообразования и устраните выживших мутантов, добавляя настоящие проверки, часто на граничных значениях вроде нуля, точных порогов и 100%. Сделайте ценообразование чистой функцией над value objects, чтобы эти тесты оставались быстрыми. И наконец, добавьте в код защиту, которая отклоняет отрицательную сумму и поднимает алерт, потому что тесты снижают риск, но никогда его не устраняют.

03

Мобильное приложение сломалось после «безобидного» изменения API

Проблема

Бэкенд-разработчик переименовал поле и сделал другое nullable. Все тесты бэкенда прошли, но мобильное приложение падало на экране заказа у каждого пользователя.

Как решить

Контракт между двумя приложениями никто не проверял. В краткосрочной перспективе верните старое поле рядом с новым и выпустите исправление. В долгосрочной — сделайте контракт исполняемым: генерируйте документ OpenAPI из кода или поддерживайте его вручную и валидируйте по нему реальные ответы в feature-тестах, чтобы переименованное или ставшее nullable поле роняло CI. Для приложений, которыми вы тоже владеете, рассмотрите consumer-driven contract tests с Pact, чтобы ожидания мобильной команды проверялись на каждой сборке бэкенда. Добавьте в код-ревью правило: изменения ответов только аддитивные, а удаления проходят через версионированную депрекацию.

Шпаргалка

Запустить тесты Laravel параллельно
php artisan test --parallel
Запустить один тест Pest по имени
./vendor/bin/pest --filter="places an order"
Ронять CI ниже порога покрытия
./vendor/bin/pest --coverage --min=80
Блокировать любой не подменённый HTTP-вызов в тестах
Http::preventStrayRequests();
Однократный запуск Vitest с покрытием
npx vitest run --coverage
Разделить Playwright на четыре CI-машины
npx playwright test --shard=1/4
Открыть трейс падения Playwright
npx playwright show-trace trace.zip
Записать тест Playwright кликами
npx playwright codegen https://staging.example.com
Мутационное тестирование для PHP
vendor/bin/infection --min-msi=70 --threads=4
Мутационное тестирование для JS и TS
npx stryker run

В моих реальных проектах

  • На платформе зоотоваров, которую я веду, 95 файлов бэкенд-тестов покрывают её Laravel-сервисы и эндпоинты.
  • В CRM для сети спортзалов, которую я построил почти в одиночку, 31 тестовый файл.
  • В мультитенантном POS SaaS, который я веду вместе с коллегой, около 270 тестовых файлов на node:test, и в тестах каждого модуля есть случай с «чужим» тенантом.
  • В приложении для кофейни post-edit-хук запускает проверку типов, линтер и проверку паритета локалей относительно записанного baseline, а для UI-работы я использую шаг проверки в браузере на ширинах 390, 768 и 1920.

Вопросы и ответы

Q1В чём разница между unit-, интеграционными и end-to-end-тестами?

Unit-тест проверяет один небольшой кусок — функцию или класс — изолированно, без базы данных и сети; он выполняется за миллисекунды. Интеграционный или feature-тест проверяет несколько частей вместе — например, HTTP-запрос через роутинг, валидацию, сервисы и настоящую тестовую базу — и проверяет ответ и сохранённые данные. End-to-end-тест запускает настоящее приложение так, как это сделал бы пользователь, в браузере или на устройстве, через фронтенд и бэкенд. Чем выше уровень, тем реалистичнее тесты, но они медленнее и хрупче, поэтому нужно много unit-тестов, солидный интеграционный слой и мало E2E.

Q2Что бы вы протестировали первым в продукте без тестов?

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

Q3Когда стоит мокать, а когда нет?

Мокайте то, чем вы не владеете и что медленно, дорого или непредсказуемо: платёжные шлюзы, SMS- и email-провайдеров, ERP, часы, случайность. Оберните каждое в собственный адаптер и подменяйте его фейком на этой границе, включая ответы с ошибками и таймаутами. Не мокайте то, чем владеете сами, — свои сервисы или базу данных: тест, который мокает ORM, доказывает лишь то, что вы вызвали метод, а не то, что запрос работает. Для базы данных используйте настоящий движок в Docker с транзакциями или очисткой между тестами. Избыток моков даёт зелёные тесты и сломанный продакшен.

Q4Что делать с нестабильными (flaky) тестами?

Относиться к ним как к багам, потому что команда, привыкшая игнорировать красные сборки, перестаёт доверять тестам. Типичные причины — общее состояние между тестами, зависимость от порядка запуска, реальное время и часовые пояса, случайность, сетевые вызовы, а в E2E — ожидание по таймаутам вместо условий. Лечение: изолировать данные для каждого теста, замораживать время, фиксировать seed случайности, подменять внешние вызовы и использовать автоожидающие assertions в Playwright. Нестабильный тест лучше ненадолго отправить в карантин с ответственным и сроком, чем позволять ему случайно падать. Трейсы Playwright и артефакты CI обычно быстро показывают причину.

Q5Pest или PHPUnit, Jest или Vitest?

Pest работает поверх PHPUnit, так что это вопрос синтаксиса: Pest лаконичнее и читабельнее и стал частым выбором по умолчанию в новых Laravel-проектах, а классы PHPUnit тоже хороши и полностью поддерживаются. Смешивать оба в одном проекте можно. Для TypeScript мой выбор по умолчанию для новых проектов — Vitest: быстрый, ESM-нативный, работает с Vite и предлагает совместимый с Jest API. Jest по-прежнему распространён в React Native и старых кодовых базах, так что мигрировать рабочий набор тестов незачем. Для Node-бэкендов встроенный раннер node:test — надёжный вариант без зависимостей. Инструмент важен меньше, чем осмысленные тесты.

Вопросы уровня senior

S1Как сохранить большой набор тестов быстрым по мере роста кодовой базы?

Относитесь к скорости набора как к продуктовой метрике с бюджетом, скажем меньше десяти минут в CI. Структурно держите большую часть логики в чистых функциях, покрытых быстрыми юнит-тестами, а для эндпоинтов используйте feature-тесты с настоящей базой, изолированные транзакциями, а не повторными миграциями. Запускайте параллельно локально и шардируйте по машинам в CI с кэшированием зависимостей и базой на tmpfs. Убирайте медленные паттерны: sleep, реальные сетевые вызовы, огромные фабрики, строящие целые графы объектов, когда хватило бы двух моделей. Каждую неделю отслеживайте самые медленные тесты и исправляйте главных нарушителей. В монорепозитории на pull requests запускайте только затронутые тесты, но перед мержем всегда полный набор. Компромисс — вложения в тестовую инфраструктуру, которые окупаются тем, как часто люди запускают тесты.

S2Какую стратегию тестирования вы используете для системы из нескольких приложений и сервисов?

Каждый компонент тщательно тестирует себя изолированно, а для швов есть отдельные дешёвые проверки. Внутри сервиса: юнит-тесты для доменной логики и feature-тесты против настоящей базы, где внешние системы подменены на уровне адаптера. Между компонентами: контрактные тесты, либо consumer-driven с Pact, либо валидация схемы по OpenAPI, чтобы производители узнавали о ломающих изменениях в собственном CI. У общих пакетов свои тесты и семантические версии или прогоны затронутого в монорепозитории. Затем тонкий end-to-end слой покрывает несколько критичных для выручки сценариев в staging-окружении. Полные end-to-end наборы через множество сервисов медленные и нестабильные, поэтому они не могут быть основной страховочной сеткой. Оставшийся разрыв закрывают мониторинг продакшена, синтетические проверки и feature flags.

S3Хорошая ли идея — целевой процент покрытия?

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

S4Тесты или мониторинг: во что вкладываться, когда времени мало?

В оба, нацеленные на разные сбои. Тесты ловят то, что можно предвидеть, до того как это увидят пользователи: логические ошибки, регрессии, сломанные контракты. Мониторинг ловит то, что предвидеть было нельзя: реальные формы данных, падения сторонних сервисов, производительность под реальной нагрузкой, ошибки конфигурации. При нехватке времени я пишу feature-тесты для денег, аутентификации и ключевых переходов состояний, потому что эти сбои дорогие и предсказуемые. Затем добавляю отслеживание ошибок с алертами, health checks, структурированные логи с request id и несколько бизнес-метрик, например заказы в час, которые обнаруживают тихие сбои, не предусмотренные ни одним тестом. Feature flags и быстрые откаты снижают цену того, что проскочило. Компромисс явный: тесты покупают предотвращение, наблюдаемость покупает быстрое обнаружение и восстановление, и продукту нужен минимум того и другого.

Глоссарий

Test double
Любая замена реальной зависимости в тесте: dummy, stub, fake, spy или mock.
Fake
Рабочая, но упрощённая реализация, например платёжный шлюз в памяти, используемая вместо настоящей.
Stub
Дублёр, который возвращает заготовленные ответы на вызовы, не проверяя, как его использовали.
Mock
Дублёр, который проверяет, что его вызвали ожидаемым образом, например один раз с определёнными аргументами.
Fixture
Подготовленные данные или файлы, на которые опирается тест, например записанный ответ API или заполненная база.
Flaky test
Тест, который иногда проходит, а иногда падает без каких-либо изменений в коде.
Property-based тестирование
Проверка того, что правило выполняется для множества сгенерированных входных данных, а не для нескольких написанных вручную примеров.
Mutation score
Доля намеренно внесённых багов, которые обнаруживает набор тестов; мера силы тестов.
Контрактный тест
Тест, проверяющий, что провайдер и его потребители всё ещё согласны в форме запросов и ответов.

Есть идея продукта?Давайте создадим его.

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