Skip to content

Компонентные тесты на практике. Скилл для агента и разбор passkey-demo-api

В предыдущей статье мы вывели формулу:

N_тестов = 1 + Σ (число различимых веток в адаптере i)

Теория готова. Время превратить её в инструмент, который работает каждый день — на любом сервисе, в руках любого вайб-инженера, в связке с ИИ-агентом.

В этой главе рассмотрим:

Что мы строим

Цель — сделать так, чтобы вайб-инженер не пересказывал агенту правила компонентных тестов перед каждой задачей. Один раз положить в репозиторий скилл — и агент будет применять его автоматически каждый раз, когда нужно сгенерировать или дополнить компонентные тесты.

Скилл это:

  • короткий markdown-файл с YAML-фронтматтером;
  • содержит процедуру: пошаговый алгоритм для агента;
  • живёт в репозитории сервиса, версионируется вместе с кодом;
  • открытый стандарт SKILL.md от Anthropic, поддерживаемый Claude Code, Cursor, GitHub Copilot, OpenAI Codex и другими агентами.

В паре с AGENTS.md (открытый стандарт OpenAI / Linux Foundation для инструкций уровня проекта) скилл становится частью контракта между разработчиком и ИИ. AGENTS.md — обзор правил. Скиллы — конкретные процедуры.

Это согласуется с принципом из статьи о документации как продукте: документация делается под потребителя. Один из потребителей — ИИ-агент. Скилл — это документация, написанная специально для него.

Порядок: контракт первичен

Важная деталь, без которой алгоритм не работает. По флоу разработки в AGENTS.md шаблона сервиса Ubik (правило 9):

backlog.md → API-контракт → README → 
Компонентные тесты (красные) → 
TDD-цикл по модулям

Компонентные тесты пишутся до кода сервиса. Значит, режимы отказа интеграций должны быть зафиксированы в контрактах, не выведены из кода адаптеров. Код — следствие контракта, не источник.

Это меняет всё. Скилл и алгоритм построены вокруг чтения контракта, не кода.

Формула в практической форме

В теоретической статье формула выводилась для канонического случая — сервиса с одним эндпоинтом:

N_тестов = 1 + Σ (число различимых веток в адаптере i)

Реальный сервис обычно имеет несколько ресурсов и действий. Каждый эндпоинт API — отдельное утверждение в спецификации, отдельный happy-path сценарий.

Практическая форма формулы:

N_тестов = N_эндпоинтов_API + Σ (число различимых режимов отказа интеграции i)

Логика та же. Один эндпоинт = одно утверждение спецификации = один happy-path сценарий. Один режим отказа = одно отдельное утверждение в контракте = один сценарий.

В учебном случае N_эндпоинтов = 1, в реальном — обычно от 3 до 10.

Для AsyncAPI каждое событие (publish или subscribe) даёт +1 к N_эндпоинтов: это тоже отдельное утверждение контракта.

Где живут режимы отказа

Это главный практический вопрос. Если контракт первичен, где именно в контракте лежат режимы отказа интеграций?

Где живёт контракт между сервисами и где — карта инфраструктурных отказов

OpenAPI и AsyncAPI описывают контракт между сервисами: какие запросы и сообщения ходят, какие ответы возвращаются.

5xx-ошибки в OpenAPI описывают, потому что клиент их видит и должен на них реагировать — это часть HTTP-протокола. Это часть контракта между двумя сервисами.

А вот «брокер упал, и мы не смогли опубликовать сообщение» — это не часть контракта между сервисами. Это поведение конкретного сервиса при отказе его собственной инфраструктуры. Соседний сервис, который читает из брокера, ничего об этом не знает: для него либо сообщение пришло, либо нет.

То же касается «БД недоступна на стороне адаптера» — это не контракт между клиентом API и сервисом (хотя клиент увидит 5xx), а поведение сервиса в инфраструктурной проблеме, описанное на двух языках: на языке HTTP-контракта (5xx в OpenAPI) и на языке внутренней карты режимов (в README).

Этот жанр информации — карта инфраструктурных отказов — правильно лежит в README, а не в AsyncAPI. AsyncAPI не «недоделан» в части ошибок: он просто не должен описывать отказы инфраструктуры, это не его жанр.

OpenAPI — для синхронных эндпоинтов

Для каждого эндпоинта в responses описаны все коды ответа. 5xx-ответы с разбивкой по error.code — это и есть режимы отказа интеграций.

Пример:

paths:
  /v1/registrations:
    post:
      responses:
        '201':
          description: Challenge создан
        '422':
          description: Невалидный handle
        '503':
          description: БД недоступна
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                code: db_unavailable
                message: Database is unavailable

Один error.code: db_unavailable под 503 = один режим отказа = один компонентный сценарий.

Если бы под 503 было два разных error.code (db_unavailable и db_timeout) — это два режима, два сценария.

Клиентские ошибки (4xx) в OpenAPI описываем тоже, но компонентные сценарии под них не пишем: проверка валидации полей — юнит-уровень.

README — для асинхронных интеграций и общей картины

AsyncAPI описывает контракт сообщения, но «коды отказа» в стиле HTTP в нём не нативны. Поэтому режимы отказа для брокеров, очередей и любых интеграций, не описанных через HTTP-коды, фиксируются в README, в отдельном разделе «Карта режимов отказа».

Это операционный документ текущего состояния — что сервис обещает делать в режиме отказа. Видимый всем потребителям сервиса (соседним командам, разработчикам, агентам).

Формат — простая таблица. В README раздел ## Карта режимов отказа содержит:

Интеграция Тип Режим отказа Поведение сервиса наружу
PostgreSQL sync Недоступна 503, error.code=db_unavailable
Kafka publish Брокер недоступен retry с backoff, DLQ после 3 попыток
Kafka subscribe Сообщение не парсится DLQ

Каждая строка таблицы = один компонентный сценарий.

«Почему именно так» (например, «почему не различаем таймаут и недоступность БД») — это уже архитектурное решение, оно лежит в /docs/adr как ADR. Карта в README говорит что есть, ADR — почему так.

Fallback: код адаптера

Если ни в OpenAPI, ни в карте режимов отказы не детализированы (например, легаси-сервис, контракт не приведён в порядок) — скилл может прочитать код адаптера как fallback. Но при этом обязательно создаёт TODO в backlog: «зафиксировать режимы отказа в контракте».

Это компенсация недостаточной проработки контракта, не норма.

Алгоритм вывода числа тестов

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

Шаг 1. Прочитать API-контракты. Открыть api-specification/openapi.yaml. Перечислить все эндпоинты (метод + путь). Если есть api-specification/asyncapi.yaml — перечислить все события.

Шаг 2. Сгенерировать happy-path сценарии. На каждый эндпоинт и каждое событие — один сценарий с заголовком, описывающим штатное поведение.

Шаг 3. Определить режимы отказа из контрактов.

  • Для синхронных эндпоинтов — из OpenAPI: все 5xx с разбивкой по error.code.
  • Для асинхронных интеграций и общей картины — из README раздел «Карта режимов отказа».
  • Если контракт не детализирован — fallback на код адаптера и TODO в backlog.

Шаг 4. Сгенерировать сценарии отказа. На каждый режим отказа — один сценарий «отказ интеграции X с конкретным типом ошибки».

Шаг 5. Сложить и сверить. N_сценариев = N_эндпоинтов + N_событий + Σ режимы отказа. Расхождение — сигнал ошибки в подсчёте.

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

Разбор passkey-demo-api

passkey-demo-api — реальный сервис платформы Ubik. WebAuthn-авторизация без паролей, JWT с подписью Ed25519, SQLite как хранилище. Прогоним по нему алгоритм.

Шаг 1. Эндпоинты по OpenAPI

Открываем api-specification/openapi.yaml. Получаем шесть эндпоинтов:

Метод Путь Что делает
POST /v1/registrations Создать challenge регистрации
POST /v1/registrations/{id}/attestation Завершить регистрацию, выдать JWT
POST /v1/sessions Создать challenge входа
POST /v1/sessions/{id}/assertion Завершить вход, выдать JWT
DELETE /v1/sessions/current Выход — инвалидация refresh token
GET /v1/users/me Получить текущего пользователя

AsyncAPI у этого сервиса нет — он чисто синхронный.

N_эндпоинтов = 6.

Шаг 2. Happy-path сценарии

Каждый эндпоинт получает свой сценарий. Главное правило — не склеивать фазы двухфазного протокола в один сценарий. Регистрация состоит из двух шагов: создать challenge, потом подтвердить. Это два отдельных эндпоинта, два отдельных контракта наружу. Если их склеить, при поломке одной фазы будут падать оба сценария — и регистрации, и входа, который тоже двухфазный — и непонятно, где именно ошибка.

Кроме того, каждый эндпоинт имеет независимый контракт: некорректный вызов второго шага без первого должен вернуть свою ошибку, и это часть спецификации второго эндпоинта, не «общего протокола».

Получается шесть happy-path сценариев — по одному на эндпоинт.

Шаг 3. Определить режимы отказа

Открываем README, раздел «Стек»:

Компонент Технология
Язык Go
Аутентификация WebAuthn (FIDO2)
Токены JWT, подпись Ed25519
База данных SQLite

Применяем правило: интеграция = выход за процесс сервиса.

  • SQLite — внешнее хранилище через драйвер. Это интеграция (файловая система через драйвер БД).
  • WebAuthn-библиотека — Go-пакет, локальный код в памяти процесса. Это модуль, не интеграция.
  • Ed25519 — криптографическая библиотека, чистая функция от ключа и данных. Модуль.
  • JWT — формирование токена в памяти. Модуль.

Интеграция одна — SQLite.

Теперь читаем OpenAPI для каждого эндпоинта. В секции responses у всех шести эндпоинтов есть один общий 5xx-ответ:

'503':
  description: Database unavailable
  content:
    application/json:
      example:
        code: db_unavailable

Один error.code на всех — db_unavailable. Это проектное решение: сервис не различает типы отказов БД (таймаут, недоступность, нарушение целостности) — все они мапятся в одну ошибку наружу.

Если бы в OpenAPI было два error.code (например, db_unavailable и db_timeout) — было бы два режима отказа. Один — один.

В README карта режимов отказа должна содержать строку:

| SQLite | sync | Недоступна | 503, error.code=db_unavailable |

Если карты в README нет — TODO в backlog: «дополнить README картой режимов».

Различимых режимов отказа — 1.

Шаг 4. Сценарий отказа

На один режим — один сценарий: «БД недоступна → клиент получает 503 с error.code=db_unavailable».

Сценарий привязывается к одному из эндпоинтов (логично — к POST /v1/registrations, первой точке входа протокола). Дублировать «БД недоступна» для всех шести эндпоинтов не нужно — мы уже доказали, что сервис корректно обрабатывает реальный отказ контейнера. Логика дальнейшего поведения на других эндпоинтах — юнит-тест.

Шаг 5. Итог

6 happy-path сценариев (по одному на эндпоинт)
+ 1 сценарий отказа БД (один режим из OpenAPI)
= 7 компонентных тестов

Это полная спецификация passkey-demo-api как чёрного ящика. Ни одного теста больше, ни одного меньше.

Раскладка по файлам

Один .feature файл на ресурс. Получается четыре файла:

component-tests/
├── registrations.feature       # 2 happy-path + 1 отказ БД
├── sessions.feature            # 2 happy-path
├── sessions-current.feature    # 1 happy-path (DELETE /v1/sessions/current)
└── users.feature               # 1 happy-path (GET /v1/users/me)

Пример registrations.feature:

Feature: Регистрация пользователя

  Scenario: Создание challenge регистрации
    Given сервис запущен
      And БД доступна
    When клиент отправляет POST /v1/registrations с валидным handle
    Then ответ 201 Created с полями id и options
      And запись challenge сохранена в БД

  Scenario: Завершение регистрации с валидной подписью
    Given сервис запущен
      And БД доступна
      And в БД есть действующий challenge с id "abc-123"
    When клиент отправляет POST /v1/registrations/abc-123/attestation
         с валидной WebAuthn-подписью
    Then ответ 200 OK с JWT-токеном
      And пользователь сохранён в БД
      And challenge помечен как использованный

  Scenario: Отказ БД при создании challenge
    Given сервис запущен
      And БД недоступна
    When клиент отправляет POST /v1/registrations с валидным handle
    Then ответ 503 Service Unavailable с error.code=db_unavailable
      And challenge не создан

Три сценария в файле. Читается как русский текст. Прогоняется машиной — реальный сервис в Docker Compose, реальный SQLite, реальные HTTP-запросы.

Если бы у сервиса был AsyncAPI

passkey-demo-api синхронный, но рассмотрим гипотетический случай. Допустим, после регистрации сервис публикует событие user_registered в Kafka.

Тогда:

  • AsyncAPI описывает контракт сообщения user_registered (схему, канал, операцию publish).
  • В README в карте режимов отказа добавляется строка для Kafka.

Расчёт меняется так:

N_тестов = (6 эндпоинтов + 1 событие) + 1 режим SQLite + 1 режим Kafka
        = 7 + 2 = 9 тестов

Семь happy-path: шесть от эндпоинтов и один на публикацию события user_registered в брокер. И два сценария отказа: БД недоступна и брокер недоступен.

Что НЕ попало в эти 7 тестов

И что не должно туда попасть:

  • Валидация формата handle — длина, допустимые символы. Юнит-уровень, модуль валидации.
  • Проверка подписи Ed25519 — юнит модуля криптографии.
  • Истечение challenge по таймауту — юнит модуля управления challenge.
  • Корректность JWT — юнит модуля выдачи токенов.
  • Двухфазная логика связи между эндпоинтами — юниты головных модулей.
  • Сборка и запуск Go-приложения — контролирует go build.

Если возникает соблазн добавить что-то из этого списка в .feature файл — это сигнал, что юнит-тесты не покрывают логику. Лечится не компонентным тестом, а возвращением к проектированию.

Скилл для агента

Теперь превращаем процедуру в скилл, который агент применит автоматически.

Скилл — это директория с файлом SKILL.md. YAML-фронтматтер с name и description сообщает агенту, когда применять. Тело — markdown-инструкции.

Полный текст skills/component-tests/SKILL.md:

---
name: component-tests
description: Генерация компонентных тестов для сервиса с OpenAPI или AsyncAPI контрактом. Применять, когда нужно создать или дополнить .feature файлы в директории component-tests на основе спецификации API. Не применять для юнит-тестов или контрактных тестов между сервисами.
---

# Компонентные тесты — спецификация чёрного ящика

Skill для генерации компонентных тестов сервиса. Тест — это исполняемая
спецификация: что сервис обещает делать на штатном пути и при отказе
каждой внешней связи. Не охота за багами и не покрытие кода.

## Принцип: контракт первичен

Компонентные тесты пишутся до кода сервиса (TDD-цикл). Значит, режимы
отказа интеграций должны быть зафиксированы в контрактах, не выведены
из кода адаптеров. Код — следствие контракта, не источник.

Источники режимов отказа в порядке приоритета:

1. OpenAPI (api-specification/openapi.yaml) — для синхронных эндпоинтов.
   Все 5xx-ответы с разбиением по error.code.

2. README раздел «Карта режимов отказа» — для асинхронных интеграций
   и любых интеграций, не описанных через коды HTTP.

3. Fallback: код адаптера — если контракт ещё не детализирован.
   Создать TODO в backlog: «зафиксировать режимы отказа в контракте».

## Формула

    N_тестов = N_эндпоинтов_API + Σ (число различимых режимов отказа интеграции i)

* N_эндпоинтов_API — количество эндпоинтов в OpenAPI плюс количество
  событий (publish и subscribe) в AsyncAPI.

* Различимый режим отказа — каждый отдельный тип ошибки, который сервис
  обещает наружу для данной интеграции.

## Что считать интеграцией

Интеграция — это выход за процесс сервиса:
* сеть (HTTP, gRPC, очереди, БД через драйвер);
* файловая система;
* IPC (Inter-Process Communication, межпроцессное взаимодействие).

Локальные библиотеки в памяти процесса — модули, не интеграции.

## Алгоритм генерации тестов

Шаг 1. Прочитать api-specification/openapi.yaml. Перечислить эндпоинты.
       Если есть asyncapi.yaml — перечислить события.

Шаг 2. На каждый эндпоинт и каждое событие — один happy-path сценарий.

Шаг 3. Определить режимы отказа из контрактов:
       — синхронные: все 5xx из OpenAPI с разбивкой по error.code;
       — асинхронные: строки таблицы «Карта режимов отказа» из README;
       — fallback: код адаптера + TODO в backlog.

Шаг 4. На каждый режим отказа — один сценарий.

Шаг 5. Сверить: N = N_эндпоинтов + N_событий + Σ режимы отказа.

## Структура файлов

* Один .feature файл на ресурс API.
* Имя файла = имя ресурса в нижнем регистре.
* Расположение: component-tests/<resource>.feature.

## Что НЕ делать

* Не выводить режимы отказа из кода как основной путь.
* Не генерировать сценарии валидации полей запроса (юнит-уровень).
* Не повторять бизнес-логику (доказана юнитами).
* Не писать smoke-test на запуск (контролирует компилятор).
* Не добавлять сценарии «на всякий случай».
* Не склеивать фазы протокола в один сценарий.

## Запуск

Все компонентные тесты запускаются в Docker Compose с реальными
зависимостями. Тест дёргает настоящий API сервиса. Никаких моков.

## Чек-лист перед коммитом

1. Карта режимов отказа в README заполнена и актуальна.
2. Число .feature файлов = число ресурсов в API.
3. Число happy-path сценариев = число эндпоинтов + число событий.
4. Число сценариев отказа = сумма режимов отказа из OpenAPI и README.
5. Все сценарии запускаются в Docker Compose с реальными зависимостями.
6. Нет валидации полей, бизнес-логики, smoke-тестов.

В YAML-фронтматтере критическая строка — description. Именно по ней агент решает применять скилл или нет. Описание начинается с условия «когда применять» и явно перечисляет случаи, когда не применять. Это не косметика — это контракт активации.

Где скилл живёт в репозитории

Структура шаблона сервиса Ubik:

service-template/
├── AGENTS.md
├── README.md                      # включает раздел «Карта режимов отказа»
├── api-specification/
│   ├── openapi.yaml
│   └── asyncapi.yaml
├── component-tests/
│   └── *.feature
├── docs/
│   └── adr/                       # обоснование решений по картe режимов
└── skills/
    └── component-tests/
        └── SKILL.md

Директория skills/ — на верхнем уровне репозитория. Это согласуется с практикой Anthropic в публичных репозиториях skills и конвенцией открытого стандарта SKILL.md.

В AGENTS.md раздел про компонентные тесты остаётся коротким — несколько строк принципа и ссылка на скилл:

### Компонентные тесты

* Тест = исполняемая спецификация чёрного ящика.
* Контракт первичен: режимы отказа — в OpenAPI и в README раздел «Карта режимов отказа».
* Формат: Gherkin (Given / When / Then).
* Запуск: Docker Compose с реальными зависимостями.
* Полная процедура генерации: skills/component-tests/SKILL.md

Жанровое разделение: AGENTS.md — обзор правил, скиллы — процедуры. Будущие скиллы (миграции БД, ADR, observability) лягут в ту же skills/ рядом с component-tests/.

Что меняется в работе с агентом

До скилла. Каждый раз нужно объяснять агенту: «компонентные тесты — это спецификация, считай по формуле, не дублируй валидацию, используй Gherkin, читай OpenAPI, не код». Агент забывает между сессиями, забывает в середине задачи, генерирует «ещё на всякий случай».

Со скиллом. Кладём SKILL.md в репозиторий один раз. При любой задаче, связанной с компонентными тестами, агент:

  1. Видит описание скилла в активных правилах проекта.
  2. Загружает полный текст в контекст.
  3. Применяет процедуру шаг за шагом, читая контракты, не код.
  4. Возвращает результат, который соответствует формуле и конвенциям.

Это и есть рациональная разработка: правила формализованы, агент применяет их детерминированно, человек контролирует результат.

И главное — скилл становится общим знанием команды. Не «у Серёжи в голове правильное понимание тестов, а у Лены другое». Скилл лежит в репозитории, версионируется, ревьюится. Если правило меняется — меняется скилл, и все следующие задачи идут уже по новому.

Резюме

  • Контракт первичен. Режимы отказа фиксируются в OpenAPI (синхронные) и в README раздел «Карта режимов отказа» (асинхронные и общая картина). Код — следствие, не источник.
  • Формула в практической форме: N_тестов = N_эндпоинтов_API + Σ (число различимых режимов отказа интеграции i).
  • Алгоритм из пяти шагов даёт детерминированный результат для любого сервиса.
  • passkey-demo-api: 6 эндпоинтов + 1 режим отказа SQLite = 7 компонентных тестов.
  • Скилл skills/component-tests/SKILL.md автоматизирует процедуру для агента.

Что дальше

Следующая статья — менеджерская. Возьмём типовой сервис на фиксированных ресурсах, замерим время прогона набора тестов, экстраполируем на масштаб организации (50 команд × 50 сервисов). Цифры, которых не хватало в этой и предыдущей статьях.

После — серия про правильное проектирование модулей: контроль диапазонов значений, возврат ошибки как данных, композиция через результат.

Источники

  1. Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.
  2. Morev M. Правильность программы. codemonsters.team, 2025.
  3. Morev M. Модульность программы. codemonsters.team, 2025.
  4. Morev M. README — это продукт. codemonsters.team, 2026.
  5. Adzic G. Specification by Example. Manning, 2011.
  6. ubik-life/passkey-demo-api — учебный сервис для разбора.
  7. ubik-life/service-template — шаблон сервиса с AGENTS.md.
  8. AGENTS.md — открытый стандарт для агентских инструкций (OpenAI / Linux Foundation).
  9. SKILL.md — открытый стандарт для агентских скиллов (Anthropic).