Компонентные тесты на практике. Скилл для агента и разбор passkey-demo-api
В предыдущей статье мы вывели формулу:
Теория готова. Время превратить её в инструмент, который работает каждый день — на любом сервисе, в руках любого вайб-инженера, в связке с ИИ-агентом.
В этой главе рассмотрим:
- Что мы строим
- Формула в практической форме
- Где живут режимы отказа
- Алгоритм вывода числа тестов
- Разбор passkey-demo-api
- Скилл для агента
- Где скилл живёт в репозитории
- Что меняется в работе с агентом
Что мы строим
Цель — сделать так, чтобы вайб-инженер не пересказывал агенту правила компонентных тестов перед каждой задачей. Один раз положить в репозиторий скилл — и агент будет применять его автоматически каждый раз, когда нужно сгенерировать или дополнить компонентные тесты.
Скилл это:
- короткий markdown-файл с YAML-фронтматтером;
- содержит процедуру: пошаговый алгоритм для агента;
- живёт в репозитории сервиса, версионируется вместе с кодом;
- открытый стандарт SKILL.md от Anthropic, поддерживаемый Claude Code, Cursor, GitHub Copilot, OpenAI Codex и другими агентами.
В паре с AGENTS.md (открытый стандарт OpenAI / Linux Foundation для инструкций уровня проекта) скилл становится частью контракта между разработчиком и ИИ. AGENTS.md — обзор правил. Скиллы — конкретные процедуры.
Это согласуется с принципом из статьи о документации как продукте: документация делается под потребителя. Один из потребителей — ИИ-агент. Скилл — это документация, написанная специально для него.
Порядок: контракт первичен
Важная деталь, без которой алгоритм не работает. По флоу разработки в AGENTS.md шаблона сервиса Ubik (правило 9):
Компонентные тесты пишутся до кода сервиса. Значит, режимы отказа интеграций должны быть зафиксированы в контрактах, не выведены из кода адаптеров. Код — следствие контракта, не источник.
Это меняет всё. Скилл и алгоритм построены вокруг чтения контракта, не кода.
Формула в практической форме
В теоретической статье формула выводилась для канонического случая — сервиса с одним эндпоинтом:
Реальный сервис обычно имеет несколько ресурсов и действий. Каждый эндпоинт API — отдельное утверждение в спецификации, отдельный happy-path сценарий.
Практическая форма формулы:
Логика та же. Один эндпоинт = одно утверждение спецификации = один 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-ответ:
Один error.code на всех — db_unavailable. Это проектное решение: сервис не различает типы отказов БД (таймаут, недоступность, нарушение целостности) — все они мапятся в одну ошибку наружу.
Если бы в OpenAPI было два error.code (например, db_unavailable и db_timeout) — было бы два режима отказа. Один — один.
В README карта режимов отказа должна содержать строку:
Если карты в 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.
Расчёт меняется так:
Семь 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 в репозиторий один раз. При любой задаче, связанной с компонентными тестами, агент:
- Видит описание скилла в активных правилах проекта.
- Загружает полный текст в контекст.
- Применяет процедуру шаг за шагом, читая контракты, не код.
- Возвращает результат, который соответствует формуле и конвенциям.
Это и есть рациональная разработка: правила формализованы, агент применяет их детерминированно, человек контролирует результат.
И главное — скилл становится общим знанием команды. Не «у Серёжи в голове правильное понимание тестов, а у Лены другое». Скилл лежит в репозитории, версионируется, ревьюится. Если правило меняется — меняется скилл, и все следующие задачи идут уже по новому.
Резюме
- Контракт первичен. Режимы отказа фиксируются в OpenAPI (синхронные) и в README раздел «Карта режимов отказа» (асинхронные и общая картина). Код — следствие, не источник.
- Формула в практической форме:
N_тестов = N_эндпоинтов_API + Σ (число различимых режимов отказа интеграции i). - Алгоритм из пяти шагов даёт детерминированный результат для любого сервиса.
- passkey-demo-api: 6 эндпоинтов + 1 режим отказа SQLite = 7 компонентных тестов.
- Скилл
skills/component-tests/SKILL.mdавтоматизирует процедуру для агента.
Что дальше
Следующая статья — менеджерская. Возьмём типовой сервис на фиксированных ресурсах, замерим время прогона набора тестов, экстраполируем на масштаб организации (50 команд × 50 сервисов). Цифры, которых не хватало в этой и предыдущей статьях.
После — серия про правильное проектирование модулей: контроль диапазонов значений, возврат ошибки как данных, композиция через результат.
Источники
- Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.
- Morev M. Правильность программы. codemonsters.team, 2025.
- Morev M. Модульность программы. codemonsters.team, 2025.
- Morev M. README — это продукт. codemonsters.team, 2026.
- Adzic G. Specification by Example. Manning, 2011.
ubik-life/passkey-demo-api— учебный сервис для разбора.ubik-life/service-template— шаблон сервиса с AGENTS.md.- AGENTS.md — открытый стандарт для агентских инструкций (OpenAI / Linux Foundation).
- SKILL.md — открытый стандарт для агентских скиллов (Anthropic).