Как делать12 октября 2026 г.7 мин чтения
Вайбкодинг: от прототипа к продуктуШаг 5 из 7

Тесты для сгенерированного кода: зафиксировать поведение до правок

Коротко

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

Пошаговый урокУровень: СреднийВайбкодинг: от прототипа к продуктуПрактика: ~1,5–3 ч

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

  • написать характеризационный тест через toMatchInlineSnapshot и прочитать снимок как список решений для владельца продукта
  • описать критичный путь (оплату, отмену заказа) сценарными тестами, которые проверяют побочные эффекты и путь отказа
  • построить матрицу прав «роль × владелец × статус», которая падает при «упрощении» проверки
  • сделать прогон обязательным: npm run check в pre-commit через husky и в GitHub Actions, плюс правила линтера против .skip, .only и тестов без expect

Проверено на

Node.js 24 LTS (24.18)Vitest 5.0.4TypeScript 6.0.3ESLint 10.12, typescript-eslint 8.71, @vitest/eslint-plugin 1.6.27husky 9.1.7actionlint 1.7.12

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

Содержание
  1. 01Зачем тесты до следующей правки агента
  2. 02Характеризационный тест: снимок того, что есть
  3. 03Сценарные тесты критичных путей
  4. 04Тест-сторож: полная матрица прав
  5. 05Чтобы тесты нельзя было тихо ослабить
  6. 06Обязательный прогон: pre-commit и CI
  7. 07Ограничения и упражнения

Зачем тесты до следующей правки агента

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

Документация Claude Code начинает советы с того же: дайте агенту проверку, которую он может запустить сам, — тесты, сборку, линтер. Без неё сигнал «готово» один — «выглядит готовым», и проверяющим становитесь вы. С ней агент сам запускает проверку, читает результат и исправляет, пока она не пройдёт. Но проверка полезна, только если она проверяет правильные вещи и её нельзя тихо ослабить. Этому и посвящён урок.

Характеризационный

Что фиксирует
Текущее поведение функции на наборе входов — каким бы оно ни было
Что поймает
Любое незаметное изменение результата, в том числе «заодно» исправленные странности

Сценарный

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

Тест-сторож

Что фиксирует
Полную таблицу допустимых комбинаций, написанную вручную
Что поймает
«Упрощение» условия в проверке прав
Три вида тестов в этом уроке

Учебный модуль — заказы небольшого интернет-магазина, каким его мог написать ассистент: расчёт суммы с промокодом, правило отмены заказа и оплата через платёжный шлюз. Шлюз в тестах подменяется функцией vi.fn(), реальных платежей и ключей нет.

bash
npm init -y
npm pkg set type=module
npm install -D vitest@5.0.4 typescript@6.0.3 @types/node@24.19.2 \
  eslint@10.12.0 typescript-eslint@8.71.1 @vitest/eslint-plugin@1.6.27 husky@9.1.7
Временный проект: зависимости с точными версиями, на которых проверен урок
src/orders.ts
export type Role = "guest" | "client" | "manager" | "admin";
export type User = { id: string; role: Role };
export type OrderStatus = "new" | "paid" | "cancelled";
export type Order = { id: string; ownerId: string; status: OrderStatus; totalKzt: number };
export type Item = { priceKzt: number; qty: number };

const PROMOS: Record<string, number> = { WELCOME10: 10, VIP25: 25 };

export function calcTotal(items: Item[], promo?: string): number {
  const sum = items.reduce((acc, item) => acc + item.priceKzt * item.qty, 0);
  const percent = promo ? (PROMOS[promo.trim().toUpperCase()] ?? 0) : 0;
  return Math.round((sum * (100 - percent)) / 100);
}

export function canCancel(user: User, order: Order): boolean {
  if (order.status !== "new") return false;
  if (user.role === "admin" || user.role === "manager") return true;
  return user.role === "client" && user.id === order.ownerId;
}
Модуль заказов в том виде, в каком его оставил ассистент. До тестов ничего не меняем
src/payments.ts
import type { Order } from "./orders.js";

export interface PaymentGateway {
  charge(orderId: string, amountKzt: number, idempotencyKey: string): Promise<{ paymentId: string }>;
}

export type Deps = { orders: Map<string, Order>; gateway: PaymentGateway };

export async function payOrder(orderId: string, deps: Deps): Promise<Order> {
  const order = deps.orders.get(orderId);
  if (!order) throw new Error("ORDER_NOT_FOUND");
  if (order.status === "paid") return order;
  if (order.status !== "new") throw new Error("ORDER_NOT_PAYABLE");
  if (order.totalKzt <= 0) throw new Error("INVALID_AMOUNT");

  await deps.gateway.charge(order.id, order.totalKzt, `order-${order.id}`);
  const paid: Order = { ...order, status: "paid" };
  deps.orders.set(order.id, paid);
  return paid;
}
Оплата заказа: шлюз передаётся параметром, поэтому его легко подменить в тесте

Характеризационный тест: снимок того, что есть

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

Удобнее всего делать такой тест через встроенный снимок. Соберите интересные входы — обычные, пустые, пограничные, странные — в один объект и вызовите toMatchInlineSnapshot() без аргумента:

tests/characterization/calc-total.test.ts
import { expect, it } from "vitest";
import { calcTotal } from "../../src/orders.js";

// Characterization test: records what calcTotal does today,
// including behaviour nobody has decided on yet.
it("calcTotal: current behaviour", () => {
  const cases = {
    "one item": calcTotal([{ priceKzt: 5000, qty: 1 }]),
    "two items": calcTotal([{ priceKzt: 5000, qty: 2 }, { priceKzt: 1500, qty: 1 }]),
    "empty cart": calcTotal([]),
    "promo WELCOME10": calcTotal([{ priceKzt: 5000, qty: 1 }], "WELCOME10"),
    "promo lower case + spaces": calcTotal([{ priceKzt: 5000, qty: 1 }], " vip25 "),
    "unknown promo": calcTotal([{ priceKzt: 5000, qty: 1 }], "FREE100"),
    "rounding 0.5": calcTotal([{ priceKzt: 5, qty: 1 }], "WELCOME10"),
    "negative qty": calcTotal([{ priceKzt: 5000, qty: -1 }]),
  };
  expect(cases).toMatchInlineSnapshot();
});
До первого запуска: ожидаемые значения не заданы
terminal
$ npx vitest run

 Snapshots  1 written
 Test Files  1 passed (1)
      Tests  1 passed (1)
Первый прогон локально: Vitest вписал снимок прямо в файл теста
tests/characterization/calc-total.test.ts
import { expect, it } from "vitest";
import { calcTotal } from "../../src/orders.js";

// Characterization test: records what calcTotal does today,
// including behaviour nobody has decided on yet.
it("calcTotal: current behaviour", () => {
  const cases = {
    "one item": calcTotal([{ priceKzt: 5000, qty: 1 }]),
    "two items": calcTotal([{ priceKzt: 5000, qty: 2 }, { priceKzt: 1500, qty: 1 }]),
    "empty cart": calcTotal([]),
    "promo WELCOME10": calcTotal([{ priceKzt: 5000, qty: 1 }], "WELCOME10"),
    "promo lower case + spaces": calcTotal([{ priceKzt: 5000, qty: 1 }], " vip25 "),
    "unknown promo": calcTotal([{ priceKzt: 5000, qty: 1 }], "FREE100"),
    "rounding 0.5": calcTotal([{ priceKzt: 5, qty: 1 }], "WELCOME10"),
    "negative qty": calcTotal([{ priceKzt: 5000, qty: -1 }]),
  };
  expect(cases).toMatchInlineSnapshot(`
    {
      "empty cart": 0,
      "negative qty": -5000,
      "one item": 5000,
      "promo WELCOME10": 4500,
      "promo lower case + spaces": 3750,
      "rounding 0.5": 5,
      "two items": 11500,
      "unknown promo": 5000,
    }
  `);
});
После первого запуска: текущее поведение записано рядом с вызовами

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

terminal
$ CI=true npx vitest run

 FAIL  tests/characterization/calc-total.test.ts > calcTotal: current behaviour
Error: Snapshot `calcTotal: current behaviour 1` mismatched
 ❯ tests/characterization/calc-total.test.ts:17:17
     17|   expect(cases).toMatchInlineSnapshot();
       |                 ^

  Snapshots  1 failed
 Test Files  1 failed (1)
Тот же тест без записанного снимка при CI=true

Проверим, что тест делает свою работу. Допустим, агента попросили «почистить» расчёт, и он заодно запретил отрицательное количество:

diff
@@ -7,7 +7,7 @@ export type Item = { priceKzt: number; qty: number };
 const PROMOS: Record<string, number> = { WELCOME10: 10, VIP25: 25 };
 
 export function calcTotal(items: Item[], promo?: string): number {
-  const sum = items.reduce((acc, item) => acc + item.priceKzt * item.qty, 0);
+  const sum = items.reduce((acc, item) => acc + item.priceKzt * Math.max(0, item.qty), 0);
   const percent = promo ? (PROMOS[promo.trim().toUpperCase()] ?? 0) : 0;
   return Math.round((sum * (100 - percent)) / 100);
 }
Правка агента: выглядит разумно, но меняет поведение
terminal
$ npx vitest run tests/characterization

 FAIL  tests/characterization/calc-total.test.ts > calcTotal: current behaviour
Error: Snapshot `calcTotal: current behaviour 1` mismatched

- Expected
+ Received

@@ -1,8 +1,8 @@
  {
    "empty cart": 0,
-   "negative qty": -5000,
+   "negative qty": 0,
    "one item": 5000,

  Snapshots  1 failed
 Test Files  1 failed (1)
Характеризационный тест показывает, что именно изменилось

Тест не говорит, что новое поведение ошибочно. Он говорит, что поведение изменилось, и останавливает правку до решения человека. Если изменение задумано, снимок обновляют командой npx vitest run -u в том же коммите и объясняют причину в описании pull request. Если нет — откатывают правку.

Сценарные тесты критичных путей

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

tests/critical/payment.test.ts
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Order } from "../../src/orders.js";
import { payOrder, type Deps } from "../../src/payments.js";

function setup(order: Order) {
  const charge = vi.fn(async () => ({ paymentId: "pay_test_1" }));
  const deps: Deps = { orders: new Map([[order.id, order]]), gateway: { charge } };
  return { deps, charge };
}

describe("payment of an order", () => {
  let order: Order;
  beforeEach(() => {
    order = { id: "o-1", ownerId: "u-1", status: "new", totalKzt: 4500 };
  });

  it("charges the exact total once and marks the order as paid", async () => {
    const { deps, charge } = setup(order);

    const paid = await payOrder("o-1", deps);

    expect(paid.status).toBe("paid");
    expect(charge).toHaveBeenCalledTimes(1);
    expect(charge).toHaveBeenCalledWith("o-1", 4500, "order-o-1");
  });

  it("a repeated payment does not charge the customer twice", async () => {
    const { deps, charge } = setup(order);

    await payOrder("o-1", deps);
    await payOrder("o-1", deps);

    expect(charge).toHaveBeenCalledTimes(1);
  });

  it("a cancelled order cannot be paid", async () => {
    const { deps, charge } = setup({ ...order, status: "cancelled" });

    await expect(payOrder("o-1", deps)).rejects.toThrow("ORDER_NOT_PAYABLE");
    expect(charge).not.toHaveBeenCalled();
  });

  it("an order with a zero or negative total is never sent to the gateway", async () => {
    const { deps, charge } = setup({ ...order, totalKzt: -5000 });

    await expect(payOrder("o-1", deps)).rejects.toThrow("INVALID_AMOUNT");
    expect(charge).not.toHaveBeenCalled();
  });

  it("the order stays unpaid if the gateway fails", async () => {
    const { deps, charge } = setup(order);
    charge.mockRejectedValueOnce(new Error("GATEWAY_TIMEOUT"));

    await expect(payOrder("o-1", deps)).rejects.toThrow("GATEWAY_TIMEOUT");
    expect(deps.orders.get("o-1")?.status).toBe("new");
  });
});
Пять обещаний оплаты: сумма, идемпотентность, статус, некорректная сумма, сбой шлюза
  • Проверяйте побочные эффекты, а не только результат: сколько раз вызван шлюз и с какими аргументами.
  • Обязательно описывайте путь отказа: отменённый заказ, нулевая сумма, ошибка внешнего сервиса. Сгенерированный код чаще всего ошибается именно там.
  • Ключ идемпотентности (order-o-1) тоже часть контракта: шлюз по нему отличает повтор от новой оплаты.
  • Каждый тест готовит свои данные: функция setup создаёт свежий заказ и свою подделку шлюза.

Типичная правка ассистента, которую ловит последний тест: попросили «сделать оплату устойчивее», и ошибку шлюза обернули в try/catch с записью в лог. Код стал «надёжнее», а заказ после сбоя отмечается оплаченным, хотя денег нет:

diff
@@ -13,7 +13,11 @@ export async function payOrder(orderId: string, deps: Deps): Promise<Order> {
   if (order.status !== "new") throw new Error("ORDER_NOT_PAYABLE");
   if (order.totalKzt <= 0) throw new Error("INVALID_AMOUNT");
 
-  await deps.gateway.charge(order.id, order.totalKzt, `order-${order.id}`);
+  try {
+    await deps.gateway.charge(order.id, order.totalKzt, `order-${order.id}`);
+  } catch (error) {
+    console.warn("payment failed, will retry later", error);
+  }
   const paid: Order = { ...order, status: "paid" };
   deps.orders.set(order.id, paid);
   return paid;
Правка, которая проглатывает ошибку оплаты
terminal
$ npx vitest run tests/critical

 ❯ tests/critical/payment.test.ts (5 tests | 1 failed)
     × the order stays unpaid if the gateway fails

 FAIL  tests/critical/payment.test.ts > payment of an order > the order stays unpaid if the gateway fails
AssertionError: promise resolved "{ id: 'o-1', ownerId: 'u-1', …(2) }" instead of rejecting
+   "status": "paid",

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 29 passed (30)
Сценарный тест «заказ остаётся неоплаченным при сбое шлюза» падает

Тест-сторож: полная матрица прав

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

tests/critical/permissions.test.ts
import { describe, expect, it } from "vitest";
import { canCancel, type Order, type OrderStatus, type Role } from "../../src/orders.js";

// The rule written out by hand, not derived from the code:
// only these combinations may cancel an order. Everything else is forbidden.
const ALLOWED = new Set([
  "client/own/new",
  "manager/own/new",
  "manager/foreign/new",
  "admin/own/new",
  "admin/foreign/new",
]);

const roles: Role[] = ["guest", "client", "manager", "admin"];
const statuses: OrderStatus[] = ["new", "paid", "cancelled"];
const matrix = roles.flatMap((role) =>
  (["own", "foreign"] as const).flatMap((owner) =>
    statuses.map((status) => ({ role, owner, status, allowed: ALLOWED.has(`${role}/${owner}/${status}`) })),
  ),
);

describe("who can cancel an order", () => {
  it("the matrix covers every combination", () => {
    expect(matrix).toHaveLength(roles.length * 2 * statuses.length);
  });

  it.each(matrix)("$role, $owner order, $status → $allowed", ({ role, owner, status, allowed }) => {
    const order: Order = { id: "o-1", ownerId: "u-1", status, totalKzt: 5000 };
    const user = { id: owner === "own" ? "u-1" : "u-2", role };
    expect(canCancel(user, order)).toBe(allowed);
  });
});
24 комбинации: правило записано явным списком, а не вычислено из кода

Важная деталь: ожидание ALLOWED не вычисляется той же логикой, что в canCancel. Если скопировать условие из кода в тест, тест будет согласен с любой ошибкой — он проверит код самим собой. Список разрешённых комбинаций — это требование, его утверждает владелец продукта.

Теперь «упрощение», которое легко пропустить на ревью: условие стало короче и выглядит логичным.

diff
@@ -13,7 +13,5 @@ export function calcTotal(items: Item[], promo?: string): number {
 }
 
 export function canCancel(user: User, order: Order): boolean {
-  if (order.status !== "new") return false;
-  if (user.role === "admin" || user.role === "manager") return true;
-  return user.role === "client" && user.id === order.ownerId;
+  return order.status === "new" && user.role !== "guest";
 }
Правка агента: клиент теперь может отменить чужой заказ
terminal
$ npx vitest run tests/critical

 ❯ tests/critical/permissions.test.ts (25 tests | 1 failed)
   ❯ who can cancel an order (25)
     × client, foreign order, new → false

 FAIL  tests/critical/permissions.test.ts > who can cancel an order > client, foreign order, new → false
AssertionError: expected true to be false // Object.is equality

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 29 passed (30)
Матрица показывает ровно ту комбинацию, которая сломалась

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

Чтобы тесты нельзя было тихо ослабить

Когда проверка не проходит, у агента, как и у уставшего человека, есть короткий путь: пропустить тест, убрать неудобный expect или запустить только «свой» тест через .only. Прогон после этого зелёный:

diff
@@ -25,12 +25,10 @@ describe("payment of an order", () => {
   });
 
   it("a repeated payment does not charge the customer twice", async () => {
-    const { deps, charge } = setup(order);
+    const { deps } = setup(order);
 
     await payOrder("o-1", deps);
     await payOrder("o-1", deps);
-
-    expect(charge).toHaveBeenCalledTimes(1);
   });
 
   it("a cancelled order cannot be paid", async () => {
@@ -47,7 +45,7 @@ describe("payment of an order", () => {
     expect(charge).not.toHaveBeenCalled();
   });
 
-  it("the order stays unpaid if the gateway fails", async () => {
+  it.skip("the order stays unpaid if the gateway fails", async () => {
     const { deps, charge } = setup(order);
     charge.mockRejectedValueOnce(new Error("GATEWAY_TIMEOUT"));
Ослабление: тест без проверки и пропущенный тест
terminal
$ npm test
 Test Files  3 passed (3)
      Tests  30 passed | 1 skipped (31)

$ npm run lint
tests/critical/payment.test.ts
  27:3  error  Test has no assertions                                                       vitest/expect-expect
  48:6  error  Disabled test - if you want to skip a test temporarily, use .todo() instead  vitest/no-disabled-tests

✖ 2 problems (2 errors, 0 warnings)
Тесты зелёные, линтер — нет

Ловят это три механизма. Правила @vitest/eslint-plugin запрещают пропущенные тесты, .only и тесты без единой проверки. Параметр allowOnly: false в конфигурации Vitest роняет прогон при .only и локально, хотя по умолчанию .only запрещён только в CI.

eslint.config.js
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import vitest from "@vitest/eslint-plugin";

export default defineConfig(
  tseslint.configs.recommended,
  {
    files: ["tests/**/*.ts"],
    plugins: { vitest },
    rules: {
      ...vitest.configs.recommended.rules,
      // a skipped or focused test must not reach the main branch
      "vitest/no-disabled-tests": "error",
      "vitest/no-focused-tests": "error",
      // a test without a single expect proves nothing
      "vitest/expect-expect": "error",
    },
  },
);
Правила для папки tests: no-disabled-tests в рекомендуемом наборе — предупреждение, здесь — ошибка
vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["tests/**/*.test.ts"],
    allowOnly: false,
  },
});
terminal
$ npx vitest run
 FAIL  tests/critical/payment.test.ts > payment of an order > a cancelled order cannot be paid
Error: [Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error
 Test Files  1 failed | 2 passed (3)
.only в тесте оплаты при allowOnly: false

Третий механизм — организационный. Правила для агента записывают в файл инструкций проекта (как его устроить, рассказано в материале про AI-ассистентов в разработке), а изменения в критичных тестах требуют одобрения ответственного: GitHub автоматически запрашивает ревью у владельцев путей из CODEOWNERS, а настройка защиты ветки «Require review from Code Owners» делает это одобрение обязательным.

AGENTS.md
# AGENTS.md

## Tests

- Run `npm run check` before reporting a task as done and show the output.
- For a bug, first write a test that fails, then fix the code.
- Tests in `tests/critical/` describe payment and access rules.
  Do not edit, skip or delete them without asking.
- A failing snapshot means behaviour changed. Report the diff;
  never run `vitest -u` to make the run green.
- Never add `.skip`, `.only` or remove `expect` calls to pass a check.
Раздел о тестах в файле инструкций. Проверен markdownlint-cli2 0.23.3
.github/CODEOWNERS
# Payment and access rules need a human review
/tests/critical/ @your-org/maintainers
/tests/characterization/ @your-org/maintainers
Команда @your-org/maintainers — плейсхолдер. Синтаксис сверен с документацией GitHub, на реальном репозитории не проверялся

Обязательный прогон: pre-commit и CI

Тесты, которые запускают «когда вспомнят», не защищают. Соберите все проверки в одну команду и запускайте её в двух местах: перед коммитом на машине разработчика и в CI на каждый pull request.

package.json
{
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit",
    "lint": "eslint .",
    "check": "npm run typecheck && npm run lint && npm test",
    "prepare": "husky"
  }
}
Фрагмент package.json: check — единая точка входа для человека, агента, хука и CI; prepare добавил npx husky init
.husky/pre-commit
npm run check
npx husky init создаёт хук с npm test; заменяем на полную проверку
terminal
$ git commit -m "Simplify canCancel"
> vitest run
 ❯ tests/critical/permissions.test.ts (25 tests | 1 failed)
     × client, foreign order, new → false
 Test Files  1 failed | 2 passed (3)
      Tests  1 failed | 30 passed (31)
husky - pre-commit script failed (code 1)
exit code: 1

$ git commit --no-verify -m "Simplify canCancel"
[main 48bc214] Simplify canCancel
 1 file changed, 1 insertion(+), 3 deletions(-)
exit code: 0
Реальный прогон во временном репозитории: хук останавливает коммит, но --no-verify его обходит

Второй коммит показывает границу pre-commit: по документации Git хук пропускается флагом --no-verify, а husky отключается переменной HUSKY=0. Хук экономит время и ловит ошибку рано, но обязательной проверкой он быть не может. Обязательная — в CI:

.github/workflows/check.yml
name: check

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  check:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run check
Workflow на каждый pull request и push в main; права токена — только чтение
terminal
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.12 -verbose .github/workflows/check.yml
verbose: Found 0 parse errors in 0 ms for .github/workflows/check.yml
verbose: Found total 0 errors in 22 ms for .github/workflows/check.yml
Workflow проверен actionlint; сама команда npm ci && CI=true npm run check прогнана в чистом клоне: 31 тест, все прошли
  1. Включите защиту ветки

    В настройках репозитория для main включите «Require status checks to pass before merging» и выберите проверку check. По документации GitHub после этого слить изменения можно только при успешной проверке.

  2. Не давайте обходить правило

    По умолчанию ограничения не действуют на администраторов. Включите «Do not allow bypassing the above settings», если проект ведут один-два человека и соблазн нажать «Merge» в обход велик.

  3. Требуйте ревью владельцев

    «Require review from Code Owners» делает обязательным одобрение изменений в tests/critical/. Подробности о workflow, кэше и матрице версий — в уроке по CI/CD на GitHub Actions.

Ограничения и упражнения

  • Тесты проверяют только то, что в них записано. Матрица прав не поймает дыру в другом эндпоинте, где проверки нет вовсе.
  • Покрытие строк не равно корректности: тест без проверки покрывает код и ничего не доказывает. Поэтому правило expect-expect важнее процента покрытия.
  • Большие снимки превращаются в шум, и их начинают обновлять не читая. Снимайте компактные значения, а не целые объекты ответа.
  • Тесты, написанные тем же агентом, что и код, могут повторять его ошибку. Документация Claude Code предлагает разделять роли: одна сессия пишет тесты, другая — код под них. Критичные тесты в любом случае читает человек.
  • Следующий шаг — мутационное тестирование (например, StrykerJS): инструмент сам вносит в код мелкие «упрощения» и проверяет, что тесты их ловят.
Как проверено7 · пройдено1 · частично1 · не запускалосьсверено 12 октября 2026 г.
  • запущено, работает

    src/orders.ts, src/payments.ts, tests/characterization/calc-total.test.ts (toMatchInlineSnapshot)

    временный проект, Node.js 24.18, Vitest 5.0.4, TypeScript 6.0.3: первый vitest run записал inline-снимок (Snapshots 1 written); тот же тест без снимка при CI=true упал (Snapshot mismatched); логи run1, run2

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

    tests/critical/payment.test.ts, tests/critical/permissions.test.ts

    vitest run: 3 файла, 31 тест, все прошли; tsc --noEmit без ошибок; шлюз оплаты — vi.fn(), реальных платежей нет

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

    регрессии «правок агента» (regression1–3.diff)

    каждая правка внесена в код и прогнана: упрощение canCancel → падает «client, foreign order, new → false»; запрет отрицательного qty → diff снимка; try/catch вокруг charge → падает «the order stays unpaid if the gateway fails»; логи run4–run6, после каждой проверки код возвращён

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

    eslint.config.js (@vitest/eslint-plugin), vitest.config.ts (allowOnly: false)

    ESLint 10.12 + typescript-eslint 8.71 + @vitest/eslint-plugin 1.6.27: ослабленный тест (it.skip и удалённый expect) — npm test зелёный (30 passed | 1 skipped), eslint — 2 ошибки expect-expect / no-disabled-tests; it.only — vitest падает «Unexpected .only modifier»; логи run8, run9

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

    package.json scripts.check, .husky/pre-commit (husky 9.1.7)

    временный git-репозиторий: коммит со сломанным canCancel остановлен хуком (husky - pre-commit script failed (code 1)), с --no-verify прошёл; лог run10, run11

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

    AGENTS.md (раздел о тестах)

    markdownlint-cli2 0.23.3: 0 issues

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

    решение упражнения «роль courier»

    сначала изменён ALLOWED — упали 2 теста courier/paid; после изменения canCancel — 31 тест прошёл (30 комбинаций + проверка размера), tsc без ошибок; лог run15

  • частично

    .github/workflows/check.yml

    actionlint 1.7.12 (docker rhysd/actionlint, --rm): 0 ошибок; шаги workflow повторены в чистом git clone: npm ci && CI=true npm run check — 31 тест прошёл; на GitHub-раннере не запускался (нет репозитория)

  • не запускалось

    .github/CODEOWNERS

    синтаксис сверен с GitHub Docs (About code owners); на реальном репозитории с защитой ветки не проверялся

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

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

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

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

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

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

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