Пирамида тестирования — правило распределения автотестов: много быстрых 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 г.
Содержание
Зачем автотесты и сколько их нужно
Автотест — это код, который проверяет другой код и отвечает «работает / не работает» за секунды вместо получаса ручного прокликивания. Ценность тестов не в количестве, а в обратной связи. В статье 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 хрупкие, дорогие в написании и долгие в прогоне. Вокке советует запомнить из исходной пирамиды две вещи: писать тесты разной гранулярности и чем выше уровень, тем меньше тестов.
- 01E2E — единицыВесь продукт в браузере или на устройстве: главные пути пользователя
- 02Интеграционные — десяткиНесколько частей вместе: HTTP-маршрут, база данных, разбор JSON
- 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.
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@24export 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 } };
}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>;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);
},
};
}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, чтобы запускать их по отдельности.
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"] } },
],
},
});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" },
});
});
});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();
});
});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 });
});
});$ 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 — ошибку, которую легко сделать и трудно заметить на ревью:
--- 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",
);$ 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)Все 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, а не фиксированный
$ 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 стоит в плане библиотеки.
На каждый коммит и pull request
Линтер, проверка типов и unit-тесты: секунды, падение блокирует слияние.
Там же, следующим шагом
Интеграционные тесты с базой в контейнере; в GitHub Actions для этого есть сервисные контейнеры.
Перед выкаткой или по расписанию
Небольшой набор e2e на главные сценарии в окружении, максимально похожем на рабочее.
После выкатки
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.
Источники
- Martin Fowler — Test Pyramid
- Ham Vocke — The Practical Test Pyramid
- Kent C. Dodds — The Testing Trophy and Testing Classifications
- web.dev — Pyramid or Crab? Find a testing strategy that fits
- Google Testing Blog — Flaky Tests at Google and How We Mitigate Them
- Martin Fowler — Eradicating Non-Determinism in Tests