Как делать11 октября 2026 г.10 мин чтения
DevOps, тестирование и безопасностьШаг 9 из 12

Пирамида тестирования: unit, integration, e2e

Коротко

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

ОсновыУровень: СреднийТестирование

После материала вы сможете

  • распределить проверки одной фичи по уровням пирамиды и составить тест-план «проверка → уровень → почему»
  • объяснить, почему e2e-тестов должно быть мало, но без них нельзя, и чем пирамида отличается от трофея, ромба и сот
  • покрыть одну функцию unit- и интеграционными тестами на Vitest без дублирования проверок
  • найти причину нестабильного теста — изоляция, время, асинхронность, внешний сервис — и устранить её

Проверено на

Node.js 24 LTS (24.18)Vitest 5.0.3Express 5.3.0TypeScript 7.0.2SQLite 3.53 (node:sqlite)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Зачем автотесты и сколько их нужно
  2. 02Пирамида: unit, integration, e2e
  3. 03Трофей, ромб и соты: альтернативные взгляды
  4. 04Одна фича на двух уровнях: форма заявки
  5. 05Где мокать и как готовить тестовые данные
  6. 06Нестабильные тесты: причины и лечение
  7. 07Тест-план фичи, тесты в CI и упражнения

Зачем автотесты и сколько их нужно

Автотест — это код, который проверяет другой код и отвечает «работает / не работает» за секунды вместо получаса ручного прокликивания. Ценность тестов не в количестве, а в обратной связи. В статье Google Testing Blog «Just Say No to More End-to-End Tests» у идеальной обратной связи три свойства: она быстрая, надёжная (тест не падает без причины) и изолирует ошибку — показывает, где именно сломалось.

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

Мартин Фаулер в заметке о «формах» тестирования приводит слова Джастина Сёрлса: спорить о процентах тестов разных типов — отвлечение; важнее писать выразительные тесты с понятными границами, которые идут быстро и надёжно и падают только по полезной причине. Пирамида и её альтернативы — инструменты, чтобы к этому прийти.

Пирамида: unit, integration, e2e

Пирамиду тестов предложил Майк Кон в книге «Succeeding with Agile», а популярной её сделали заметка Мартина Фаулера «Test Pyramid» и статья Хэма Вокке «The Practical Test Pyramid». Фаулер формулирует суть так: unit-тестов должно быть намного больше, чем широких тестов через интерфейс, потому что тесты через UI хрупкие, дорогие в написании и долгие в прогоне. Вокке советует запомнить из исходной пирамиды две вещи: писать тесты разной гранулярности и чем выше уровень, тем меньше тестов.

  1. 01E2E — единицыВесь продукт в браузере или на устройстве: главные пути пользователя
  2. 02Интеграционные — десяткиНесколько частей вместе: HTTP-маршрут, база данных, разбор JSON
  3. 03Unit — сотниОдна функция или класс в памяти, зависимости подменены
Пирамида: чем выше, тем медленнее, дороже и ближе к пользователю каждый тест.

Unit

Что проверяет
Логику одной функции или класса: ветвления, граничные значения, расчёты
Скорость
Миллисекунды
Чем платим
Не видит ошибок на стыках
Пример для формы заявки
normalizePhone превращает «8 (707) 123-45-67» в +77071234567

Интеграционный

Что проверяет
Что части работают вместе: маршрут, валидация, SQL, коды ответов
Скорость
Десятки миллисекунд — секунды
Чем платим
Нужна база или её контейнер, подготовка данных
Пример для формы заявки
POST /api/leads сохраняет строку в базе и возвращает 201, дубль — 409

E2E

Что проверяет
Сценарий пользователя целиком: браузер, фронтенд, API, база
Скорость
Секунды — минуты на тест
Чем платим
Медленно, нестабильно, трудно найти причину падения
Пример для формы заявки
Пользователь заполняет форму на странице и видит «Спасибо, мы перезвоним»

Названия уровней размыты. Фаулер вспоминает специалиста, который на первом занятии своего курса разбирал 24 разных определения unit-теста. Внутри команды договоритесь о словах, а при чтении чужих советов уточняйте, что автор под ними понимает. Полезное различие из той же заметки Фаулера: «одиночный» (solitary) unit-тест подменяет всех соседей, «общительный» (sociable) использует настоящие. Многие споры «unit против интеграционных» на деле — спор об этих двух стилях.

Статья Google Testing Blog предлагает как первое приближение соотношение 70/20/10 — unit, интеграционные, e2e — и оговаривает, что точная пропорция у каждой команды своя. Там же описаны два антипаттерна: «рожок мороженого» (перевёрнутая пирамида, почти всё проверяют e2e и вручную) и «песочные часы» (много unit и e2e, а интеграционных почти нет — e2e-тесты делают работу, которую сделал бы тест поменьше).

Трофей, ромб и соты: альтернативные взгляды

Пирамиду много критикуют, и у неё есть несколько известных альтернатив. Статья web.dev «Pyramid or Crab? Find a testing strategy that fits» собирает их вместе; общий мотив почти у всех — сместить основной вес на интеграционные тесты.

Пирамида

Автор / источник
Майк Кон; Фаулер, Вокке
Главная мысль
Больше всего unit-тестов, e2e — минимум
Где уместна
Сложная логика внутри кода, библиотеки, расчёты

Трофей (testing trophy)

Автор / источник
Кент Доддс
Главная мысль
Внизу статический анализ (TypeScript, ESLint), основной объём — интеграционные тесты; «чем больше тесты похожи на реальное использование, тем больше уверенности они дают»
Где уместна
Фронтенд и монолитные приложения, где ошибки чаще на стыке компонентов

Ромб (test diamond)

Автор / источник
Описан в статье web.dev
Главная мысль
Unit-тесты только для самых критичных случаев, потому что при рефакторинге они «разрушаются»; основной слой — интеграционные
Где уместна
Приложения, где логика простая, а связок много

Соты (testing honeycomb)

Автор / источник
Spotify Engineering, 2018
Главная мысль
Для микросервисов: больше всего интеграционных тестов сервиса, немного тестов деталей реализации, а интегрированных (зависящих от правильности чужой системы) — в идеале ни одного
Где уместна
Небольшие сервисы, вся сложность которых во взаимодействии

Сам Доддс пишет, что трофей — взгляд на тесты внутри одной кодовой базы, придуманный для фронтенда; о микросервисах он не думал. А Фаулер в заметке «On the Diverse And Fantastical Shapes of Testing» показывает, что «интеграционные» тесты сторонников сот часто совпадают с тем, что он называет общительными unit-тестами, — и спор во многом терминологический.

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

Одна фича на двух уровнях: форма заявки

Возьмём форму заявки: имя, телефон и согласие на обработку данных. Правила: телефон приводим к виду +7XXXXXXXXXX, повторная заявка с того же номера в течение 24 часов — дубль, а если уведомление менеджеру не ушло, заявка всё равно сохраняется. Покроем фичу unit- и интеграционными тестами на Vitest. Чтобы пример был самодостаточным, база — SQLite в памяти из встроенного модуля node:sqlite; с PostgreSQL в Docker тот же подход разобран в уроке о тестировании backend.

bash
npm init -y && npm pkg set type=module
npm install --save-exact express@5.3.0
npm install -D --save-exact vitest@5.0.3 typescript@7.0.2 @types/express@5.0.6 @types/node@24
src/lead.ts
export type LeadInput = { name?: unknown; phone?: unknown; consent?: unknown };
export type Lead = { name: string; phone: string };
export type Validation =
  | { ok: true; lead: Lead }
  | { ok: false; errors: Record<string, string> };

// "8 (707) 123-45-67", "+7 707 123 45 67", "7071234567" -> "+77071234567"
export function normalizePhone(raw: string): string | null {
  const digits = raw.replace(/\D/g, "");
  if (digits.length === 10) return `+7${digits}`;
  if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) return `+7${digits.slice(1)}`;
  return null;
}

export function validateLead(input: LeadInput): Validation {
  const errors: Record<string, string> = {};
  const name = typeof input.name === "string" ? input.name.trim() : "";
  const phone = typeof input.phone === "string" ? normalizePhone(input.phone) : null;
  if (name.length < 2) errors.name = "name_too_short";
  if (!phone) errors.phone = "phone_invalid";
  if (input.consent !== true) errors.consent = "consent_required";
  if (!phone || Object.keys(errors).length > 0) return { ok: false, errors };
  return { ok: true, lead: { name, phone } };
}
src/lead-service.ts
import { validateLead, type Lead, type LeadInput } from "./lead";

export interface LeadRepo {
  findRecentByPhone(phone: string, since: Date): Promise<{ id: number } | null>;
  insert(lead: Lead, createdAt: Date): Promise<number>;
}
export type Notify = (lead: Lead & { id: number }) => Promise<void>;

export type SubmitResult =
  | { status: "created"; id: number }
  | { status: "duplicate"; id: number }
  | { status: "invalid"; errors: Record<string, string> };

const DAY_MS = 24 * 60 * 60 * 1000;

export function createLeadService(deps: { repo: LeadRepo; notify: Notify; now?: () => Date }) {
  const now = deps.now ?? (() => new Date());

  return {
    async submit(input: LeadInput): Promise<SubmitResult> {
      const checked = validateLead(input);
      if (!checked.ok) return { status: "invalid", errors: checked.errors };

      const at = now();
      const since = new Date(at.getTime() - DAY_MS);
      const recent = await deps.repo.findRecentByPhone(checked.lead.phone, since);
      if (recent) return { status: "duplicate", id: recent.id };

      const id = await deps.repo.insert(checked.lead, at);
      try {
        await deps.notify({ ...checked.lead, id });
      } catch {
        // the lead is already saved: a failed notification must not lose it
      }
      return { status: "created", id };
    },
  };
}

export type LeadService = ReturnType<typeof createLeadService>;
src/sqlite-repo.ts
import { DatabaseSync } from "node:sqlite";
import type { LeadRepo } from "./lead-service";

export function openDb(path = ":memory:"): DatabaseSync {
  const db = new DatabaseSync(path);
  db.exec(`CREATE TABLE IF NOT EXISTS leads (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    phone      TEXT NOT NULL,
    created_at TEXT NOT NULL
  )`);
  return db;
}

export function createSqliteRepo(db: DatabaseSync): LeadRepo {
  const findRecent = db.prepare(
    "SELECT id FROM leads WHERE phone = ? AND created_at >= ? ORDER BY id DESC LIMIT 1",
  );
  const insert = db.prepare("INSERT INTO leads (name, phone, created_at) VALUES (?, ?, ?)");

  return {
    async findRecentByPhone(phone, since) {
      const row = findRecent.get(phone, since.toISOString());
      return row ? { id: Number(row.id) } : null;
    },
    async insert(lead, createdAt) {
      const result = insert.run(lead.name, lead.phone, createdAt.toISOString());
      return Number(result.lastInsertRowid);
    },
  };
}
src/app.ts
import express from "express";
import type { LeadService } from "./lead-service";

export function createApp(leads: LeadService) {
  const app = express();
  app.use(express.json());

  app.post("/api/leads", async (req, res) => {
    const result = await leads.submit(req.body ?? {});
    if (result.status === "invalid") return res.status(422).json({ errors: result.errors });
    if (result.status === "duplicate") return res.status(409).json({ error: "duplicate", id: result.id });
    res.status(201).json({ id: result.id });
  });

  return app;
}

Сервис получает зависимости параметрами: хранилище, функцию уведомления и часы now. Это и делает его тестируемым: в unit-тесте подставим массив вместо базы и зафиксированное время, в интеграционном — настоящий SQL. Тесты разведены по двум проектам Vitest, чтобы запускать их по отдельности.

vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    projects: [
      { test: { name: "unit", include: ["tests/unit/**/*.test.ts"] } },
      { test: { name: "integration", include: ["tests/integration/**/*.test.ts"] } },
    ],
  },
});
tests/unit/lead.test.ts
import { describe, expect, it } from "vitest";
import { normalizePhone, validateLead } from "../../src/lead";

describe("normalizePhone", () => {
  it.each([
    ["8 (707) 123-45-67", "+77071234567"],
    ["+7 707 123 45 67", "+77071234567"],
    ["7071234567", "+77071234567"],
    ["87071234567", "+77071234567"],
  ])("%s -> %s", (raw, expected) => {
    expect(normalizePhone(raw)).toBe(expected);
  });

  it.each(["", "12345", "9 707 123 45 67", "+1 202 555 0100 1"])("rejects %j", (raw) => {
    expect(normalizePhone(raw)).toBeNull();
  });
});

describe("validateLead", () => {
  it("returns a normalized lead", () => {
    expect(validateLead({ name: "  Aigerim ", phone: "8 707 123 45 67", consent: true })).toEqual({
      ok: true,
      lead: { name: "Aigerim", phone: "+77071234567" },
    });
  });

  it("collects every error at once", () => {
    expect(validateLead({ name: "A", phone: "123", consent: false })).toEqual({
      ok: false,
      errors: { name: "name_too_short", phone: "phone_invalid", consent: "consent_required" },
    });
  });
});
Unit: все форматы телефона и все комбинации ошибок — здесь, а не в HTTP-тестах.
tests/unit/lead-service.test.ts
import { describe, expect, it, vi } from "vitest";
import { createLeadService, type LeadRepo } from "../../src/lead-service";

type Row = { id: number; phone: string; createdAt: Date };

// a fake repository: a plain array instead of a database
function fakeRepo(): LeadRepo & { rows: Row[] } {
  const rows: Row[] = [];
  return {
    rows,
    async findRecentByPhone(phone, since) {
      const row = rows.findLast((r) => r.phone === phone && r.createdAt >= since);
      return row ? { id: row.id } : null;
    },
    async insert(lead, createdAt) {
      rows.push({ id: rows.length + 1, phone: lead.phone, createdAt });
      return rows.length;
    },
  };
}

const valid = { name: "Aigerim", phone: "+7 707 123 45 67", consent: true };

describe("lead service", () => {
  it("rejects a repeat within 24 hours and accepts it after", async () => {
    let now = new Date("2026-10-11T09:00:00Z");
    const service = createLeadService({ repo: fakeRepo(), notify: vi.fn(), now: () => now });

    expect(await service.submit(valid)).toEqual({ status: "created", id: 1 });
    now = new Date("2026-10-12T08:59:00Z"); // 23 h 59 min later
    expect(await service.submit(valid)).toEqual({ status: "duplicate", id: 1 });
    now = new Date("2026-10-12T09:01:00Z"); // 24 h 1 min later
    expect(await service.submit(valid)).toEqual({ status: "created", id: 2 });
  });

  it("keeps the lead when the notification fails", async () => {
    const repo = fakeRepo();
    const notify = vi.fn().mockRejectedValue(new Error("telegram is down"));
    const service = createLeadService({ repo, notify });

    expect(await service.submit(valid)).toEqual({ status: "created", id: 1 });
    expect(repo.rows).toHaveLength(1);
    expect(notify).toHaveBeenCalledOnce();
  });

  it("does not touch the repository for invalid input", async () => {
    const repo = fakeRepo();
    const insert = vi.spyOn(repo, "insert");
    const service = createLeadService({ repo, notify: vi.fn() });

    const result = await service.submit({ name: "Aigerim", phone: "123", consent: true });
    expect(result.status).toBe("invalid");
    expect(insert).not.toHaveBeenCalled();
  });
});
Unit: правила сервиса с подделкой хранилища и управляемым временем.
tests/integration/leads.api.test.ts
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../src/app";
import { createLeadService } from "../../src/lead-service";
import { createSqliteRepo, openDb } from "../../src/sqlite-repo";

// real HTTP, routing, JSON parsing and SQL; only the external notifier is a double
const db = openDb(":memory:");
const notify = vi.fn().mockResolvedValue(undefined);
let now = new Date("2026-10-11T09:00:00Z");
const app = createApp(createLeadService({ repo: createSqliteRepo(db), notify, now: () => now }));

let server: Server;
let base: string;

beforeAll(async () => {
  server = app.listen(0); // port 0: the OS picks a free port
  await new Promise((resolve) => server.once("listening", resolve));
  base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise((resolve) => server.close(resolve)));

beforeEach(() => {
  db.exec("DELETE FROM leads"); // every test starts from an empty table
  notify.mockClear();
  now = new Date("2026-10-11T09:00:00Z");
});

const post = (body: unknown) =>
  fetch(`${base}/api/leads`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });

describe("POST /api/leads", () => {
  it("201: saves a normalized phone and notifies once", async () => {
    const res = await post({ name: "Aigerim", phone: "8 (707) 123-45-67", consent: true });

    expect(res.status).toBe(201);
    const { id } = await res.json();
    expect(db.prepare("SELECT name, phone FROM leads WHERE id = ?").get(id)).toEqual({
      name: "Aigerim",
      phone: "+77071234567",
    });
    expect(notify).toHaveBeenCalledOnce();
  });

  it("422: returns field errors and writes nothing", async () => {
    const res = await post({ name: "Aigerim", phone: "123" });

    expect(res.status).toBe(422);
    expect(await res.json()).toEqual({ errors: { phone: "phone_invalid", consent: "consent_required" } });
    expect(db.prepare("SELECT count(*) AS n FROM leads").get()).toEqual({ n: 0 });
  });

  it("409: the same phone in another format within 24 hours", async () => {
    await post({ name: "Aigerim", phone: "+7 707 123 45 67", consent: true });
    now = new Date("2026-10-11T20:00:00Z");
    const res = await post({ name: "Aigerim", phone: "87071234567", consent: true });

    expect(res.status).toBe(409);
    expect(db.prepare("SELECT count(*) AS n FROM leads").get()).toEqual({ n: 1 });
  });
});
Интеграция: настоящие HTTP, маршрутизация, разбор JSON и SQL; подменено только внешнее уведомление.
terminal
$ npx tsc -p .
$ npx vitest run --project unit
 Test Files  2 passed (2)
      Tests  13 passed (13)
   Duration  183ms (transform 54%, import 27%, worker 9%, tests 9%)

$ npx vitest run --project integration --reporter verbose
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 201: saves a normalized phone and notifies once 32ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 422: returns field errors and writes nothing 3ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 409: the same phone in another format within 24 hours 18ms
 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  315ms (import 46%, tests 30%, transform 22%, worker 2%)
Реальный прогон; tsc завершился без ошибок и без вывода.

Тестов на нижнем уровне больше — 13 против 3, — и они перебирают варианты: форматы телефона, граничное время «23 ч 59 мин» и «24 ч 1 мин», отказ уведомления. Интеграционные тесты не повторяют этот перебор, а проверяют по одному представителю на каждый исход HTTP и то, чего unit-тест увидеть не может. В этом примере уровни различаются по скорости мало, потому что SQLite живёт в памяти; с PostgreSQL в контейнере интеграционный прогон в нашем уроке о backend-тестах занял около 6 секунд против 0,2 секунды у unit-тестов.

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

src/sqlite-repo.ts
--- a/src/sqlite-repo.ts
+++ b/src/sqlite-repo.ts
   const findRecent = db.prepare(
-    "SELECT id FROM leads WHERE phone = ? AND created_at >= ? ORDER BY id DESC LIMIT 1",
+    "SELECT id FROM leads WHERE phone = ? AND created_at <= ? ORDER BY id DESC LIMIT 1",
   );
terminal
$ npx vitest run
 ❯ |integration| tests/integration/leads.api.test.ts (3 tests | 1 failed) 72ms
   ❯ POST /api/leads (3)
     × 409: the same phone in another format within 24 hours 31ms

 FAIL  |integration| tests/integration/leads.api.test.ts > POST /api/leads > 409: the same phone in another format within 24 hours
AssertionError: expected 201 to be 409 // Object.is equality
 ❯ tests/integration/leads.api.test.ts:63:24

 Test Files  1 failed | 2 passed (3)
      Tests  1 failed | 15 passed (16)
Реальный вывод, сокращены diff значений и фрагмент кода.

Все 13 unit-тестов прошли: подделка хранилища написана правильно, и SQL они не исполняют. Ошибку поймал только интеграционный тест на 409. Та же картина, если убрать из app.ts строку app.use(express.json()): unit-тесты зелёные, а все три интеграционных падают — тело запроса не разбирается, и сервер отвечает 422 на корректную заявку. Обратная ситуация тоже бывает: если сломать разбор формата «8 (707)…», unit-тест normalizePhone укажет на конкретную строку, а интеграционный скажет лишь, что вместо 201 пришёл 422.

Где мокать и как готовить тестовые данные

Мок, стаб, фейк — разновидности тестового дублёра (test double), объекта, который подменяет настоящую зависимость. Чем выше уровень теста, тем меньше подмен: каждая подмена — место, где тест перестаёт проверять реальность.

Unit

Что подменяем
Базу, сеть, время, генераторы случайных чисел, другие модули
Что оставляем настоящим
Тестируемую функцию или класс

Интеграционный

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

E2E

Что подменяем
Как можно меньше; внешние платные сервисы — их тестовые (sandbox) режимы
Что оставляем настоящим
Всё приложение: фронтенд, API, базу

Тестовые данные — частая причина хрупких тестов. Рабочие правила:

  • Каждый тест сам создаёт нужные данные и не рассчитывает на то, что оставил предыдущий: в примере beforeEach очищает таблицу и сбрасывает время
  • Данные минимальны и говорят сами за себя: в тесте на дубль видно, что номер тот же, а формат другой
  • Время, случайность и id не берутся из окружения: часы передаются параметром или фиксируются через vi.useFakeTimers() и vi.setSystemTime()
  • Для сложных объектов заводят фабрику с разумными значениями по умолчанию и переопределяют только важное для теста поле
  • Никаких реальных персональных данных и копий рабочей базы: телефоны и имена — выдуманные, ключи — из переменных окружения тестового стенда
  • Тесты с общей базой либо идут последовательно, либо каждый работает в своей схеме или транзакции

Нестабильные тесты: причины и лечение

Нестабильный (flaky) тест — тот, что на одном и том же коде то проходит, то падает. В Google Testing Blog описано, что во всём массиве тестов Google около 1,5% прогонов дают такой результат, а 84% наблюдаемых переходов теста из «прошёл» в «упал» связаны с нестабильными тестами. Главный вред — потеря доверия: команда привыкает перезапускать красную сборку и пропускает настоящую ошибку.

Отсутствие изоляции

Как проявляется
Тест проходит один, но падает в общем прогоне или в другом порядке
Лечение
Чистые данные в каждом тесте; прогон в случайном порядке, чтобы найти зависимость

Асинхронность

Как проявляется
sleep(500) «чтобы успело»; на медленной машине не успевает
Лечение
Ждать событие или условие (callback, опрос с таймаутом, await), а не фиксированное время

Удалённые сервисы

Как проявляется
Тест падает, когда лежит чужой API или тестовый стенд
Лечение
Дублёр на интеграционном уровне; настоящий сервис — только в отдельных контрактных или e2e-проверках

Время

Как проявляется
Тест падает около полуночи, в конце месяца или в другом часовом поясе
Лечение
Обёртка над часами, которую тест подменяет: now в примере, vi.setSystemTime()

Утечки ресурсов

Как проявляется
Чем дольше прогон, тем чаще падения: кончаются соединения, порты, память
Лечение
Закрывать серверы и соединения в afterAll; слушать порт 0, а не фиксированный
terminal
$ npx vitest run --sequence.shuffle
 Test Files  3 passed (3)
      Tests  16 passed (16)
Перемешанный порядок файлов и тестов — дешёвый способ найти тесты, зависящие друг от друга.

Фаулер в статье «Eradicating Non-Determinism in Tests» советует сразу переносить нестабильный тест в карантин — отдельный набор, который не блокирует сборку, — и быстро его чинить, ограничив размер карантина или срок пребывания в нём. Автоматические повторы (retry в Vitest, retries в Playwright, который помечает такие тесты как «flaky») снижают шум, но тот же пост Google предупреждает: повторы приучают не замечать нестабильность. Используйте их, чтобы собрать список проблемных тестов, а не чтобы спрятать его.

Тест-план фичи, тесты в CI и упражнения

Перед написанием тестов полезно разложить проверки фичи по уровням — получится короткий тест-план. Для формы заявки он выглядит так:

Форматы телефона, обязательные поля, тексты ошибок

Уровень
Unit
Почему здесь
Много вариантов, чистая логика

Дубль в течение 24 часов, граница по времени

Уровень
Unit + один интеграционный
Почему здесь
Правило — в сервисе, но работает через SQL-запрос

Отказ уведомления не теряет заявку

Уровень
Unit
Почему здесь
Подмена внешнего сервиса проще всего здесь

Коды 201, 409, 422, разбор JSON, запись в базу

Уровень
Интеграционный
Почему здесь
Проверяется только при настоящей связке маршрута и базы

Поведение формы: подписи, ошибки у полей, блокировка кнопки

Уровень
Компонентный (unit/integration фронтенда)
Почему здесь
Testing Library без браузера быстрее e2e

Пользователь отправляет форму и видит подтверждение

Уровень
E2E, один-два теста
Почему здесь
Главный сценарий, который приносит деньги

Как писать каждый уровень, разобрано отдельно: компонентные тесты — в уроке о тестировании фронтенда, unit и интеграционные тесты API с PostgreSQL — в уроке о тестировании backend, особенности телефонов и магазинов приложений — в уроке о тестировании мобильных приложений. Урок об e2e-тестах на Playwright стоит в плане библиотеки.

  1. На каждый коммит и pull request

    Линтер, проверка типов и unit-тесты: секунды, падение блокирует слияние.

  2. Там же, следующим шагом

    Интеграционные тесты с базой в контейнере; в GitHub Actions для этого есть сервисные контейнеры.

  3. Перед выкаткой или по расписанию

    Небольшой набор e2e на главные сценарии в окружении, максимально похожем на рабочее.

  4. После выкатки

    Smoke-проверка рабочего адреса и мониторинг: тесты не заменяют алерты на реальные ошибки пользователей.

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/lead.ts, src/lead-service.ts, src/sqlite-repo.ts, src/app.ts, vitest.config.ts, tsconfig.json, tests/unit/{lead,lead-service}.test.ts, tests/integration/leads.api.test.ts

    временный проект: Node 24.18.0, express 5.3.0, vitest 5.0.3, typescript 7.0.2, @types/express 5.0.6, @types/node 24.19.2, SQLite 3.53.1 из node:sqlite; tsc -p . — 0 ошибок; vitest run — 16/16 (unit 13 за ~170–180 мс, integration 3 за ~315 мс); --sequence.shuffle — 16/16

  • запущено, работает

    diff created_at >= ? → <= ? (намеренная ошибка в SQL)

    unit 13/13 прошли; integration: упал ровно 1 тест (409: expected 201 to be 409), exit 1; после отката снова 16/16

  • запущено, работает

    упражнение repeat (удалить app.use(express.json()))

    unit 13/13; integration 3/3 упали (422 вместо 201; лишняя ошибка name_too_short), решение в статье соответствует прогону

  • запущено, работает

    команды установки

    выполнены в пустой папке временного проекта

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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