Применение и доработка скилла компонентных тестов. Сессия с opus и хендофф sonnet'у
«Если карта не сходится с местностью — права местность.»
— старая военная пословица
В предыдущей статье мы зафиксировали скилл компонентных тестов как артефакт: положили SKILL.md рядом с проектом, написали процедуру, посчитали для passkey-demo-api по формуле семь сценариев. Всё красиво.
Реальность вмешалась на первой же минуте применения. Эта статья — журнал двух смежных сессий: opus прошёл от теории до зелёного smoke, потом sonnet за один день написал восемь сценариев по готовому шаблону. Карта (скилл) встретилась с местностью (реальный репозиторий), оба после этого изменились.
В этой главе рассмотрим:
- Как карта встретилась с местностью
- Раунд 1: один режим, как написано в учебнике
- Раунд 2: тимлид-критика — слишком крупная гранулярность
- Раунд 3: «а вы это вообще тестировать собираетесь?»
- Тимлид-ревью перед хендоффом
- Шаг 2.0 — шаблон тестов в Docker
- Семь вопросов по архитектуре раннера
- Шаг 2 — sonnet пишет
.feature-файлы - Уроки сессии
Как карта встретилась с местностью
Сессия началась короткой задачей:
Промпт 1 (opus). Изучи две статьи
ai-ubik-03-testing-mythology.mdиai-ubik-04-component-tests-skill-demo.md, на основании статей доработай AGENTS.md, добавь SKILL для компонентных тестов, работу начинай с новой ветки согласно правилам в AGENTS.md и CLAUDE.md.
Opus прочитал статьи, создал ветку, положил skills/component-tests/SKILL.md, обновил AGENTS.md и README.md, добавил devlog. Первый PR закрылся за полчаса.
Дальше — Шаг 0 скилла: «прочитай OpenAPI, перечисли режимы отказа из 5xx-ответов».
Пусто. Ни одного 5xx ни на одном эндпоинте. В статье 04 я уверенно писал: «у passkey-demo-api в OpenAPI один error.code: db_unavailable под 503». В реальной спеке ничего этого не было.
Контракт обещал то, чего не существовало.
Промпт 2 (opus). Описать сценарии отказа на интеграции с БД.
Здесь и началась настоящая работа.
Раунд 1: один режим, как написано в учебнике
Я сделал ровно то, что предписывал скилл:
- перечислил интеграции (одна — SQLite);
- теоретические режимы отказа (locked, timeout, disk full, corruption);
- свернул всё в один
db_unavailable→ 503; - добавил в OpenAPI и README по одной строке.
Получалось семь компонентных тестов: 6 эндпоинтов + 1 режим. Чисто, по учебнику.
Раунд 2: тимлид-критика — слишком крупная гранулярность
Промпт 3 (opus). Посмотри критически: какие ещё отказы на интеграции с БД могут быть? Сеть недоступна между сервисом и БД? Мы всё будем заворачивать в 503? Будет сложнее ошибки траблшутить.
Это разворот. Я слишком быстро согласился со статьёй и упустил операционную сторону. Один error.code на всё означает:
- ops-pipeline не различает «диск кончился» (звать оператора) и «БД отвалилась на минуту» (само пройдёт);
- клиент не знает, ретраить ли с короткой задержкой (
SQLITE_BUSY) или с длинной; - разработчик при разборе инцидента видит одинаковый код и идёт в логи, чтобы понять причину.
Мы развернули критерий. Раньше скилл говорил: «по умолчанию один режим на интеграцию, если нет явной потребности дифференцировать». Теперь — иначе:
Правило различимости. Режим — отдельный, если различается хотя бы одно из четырёх:
- HTTP-статус (503 vs 507 vs 500);
- заголовки ответа (
Retry-Afterесть/нет);- действие клиента (ретраить / не ретраить / ретраить с backoff);
- действие оператора (ничего / alert / эскалация).
Если все четыре одинаковы — режимы сворачиваются в один
error.code.
Рассмотрели четыре варианта от 1 до 5 режимов; выбрали три:
| Режим | HTTP | Заголовки | Действие клиента | Действие оператора |
|---|---|---|---|---|
db_unavailable |
503 | — | Ретраить позже | — |
db_locked |
503 | Retry-After: 1 |
Ретраить с backoff | — |
db_disk_full |
507 | — | Не ретраить | Эскалация (расширить диск) |
Стало девять компонентных тестов: 6 эндпоинтов + 3 режима. Ops-pipeline и клиент видят разное при разных причинах отказа. Стоило двух лишних тестов.
Контракт обновили: в OpenAPI появился общий ServiceUnavailable-ответ с двумя примерами error.code и заголовком Retry-After, плюс новый InsufficientStorage (507) для db_disk_full на write-эндпоинтах. README получил расширенную «Карту режимов отказа» с колонкой «поведение клиента». Скилл получил правило различимости.
Опубликовали бы — и закрыли бы шаг.
Раунд 3: «а вы это вообще тестировать собираетесь?»
Промпт 4 (opus). Failure-режимы SQLite — как ставятся в compose/тестах точно можно сделать?
Простой вопрос, перевернувший всё. Честный разбор показал:
db_locked — точно. Раннер шарит volume с SUT, открывает свой коннект к SQLite-файлу, делает BEGIN EXCLUSIVE TRANSACTION, не коммитит. SUT при попытке записи получает SQLITE_BUSY → отдаёт 503. Семантика SQLite-локов через несколько процессов давно стабильна.
db_disk_full — точно. tmpfs ограниченного размера через docker-compose, runner перед сценарием пишет junk-файл, забивает место. Первая же запись SUT падает с SQLITE_FULL → 507.
db_unavailable — оказался синтетическим. SQLite встроенная, нет «сетевой недоступности». Все способы воспроизвести «БД недоступна» либо требуют архитектурного компромисса, либо не работают:
- При старте сервис падает по AGENTS.md (panic при ошибке открытия БД) — никаких 503 не будет.
- Mid-test открытый файловый дескриптор переживает
chmod 000/unlinkпо POSIX-семантике. Сервис продолжит писать, тест не поймает отказ. - Lazy-init адаптера +
MaxIdleConns=0работает, но требует переписать архитектуру сервиса. - Unmount тома — privileged-операция, не работает на Docker Desktop.
- Test-hook в коде — анти-паттерн, тест-код в продакшне.
Промпт 5 (opus). Убери лишние синт проверки, оставь реальный возможный отказ.
Принцип, выкристаллизованный по итогу:
В контракт идут только реально воспроизводимые в тестах режимы. Не теоретически возможные.
db_unavailable отложили — он естественно вернётся при миграции на сетевую БД (Postgres). db_timeout и db_corrupt тоже отложили: первый поведением совпадает с db_locked, второй — катастрофическое событие, требует ручного вмешательства, в demo избыточен.
Финал: 2 режима, 8 тестов.
Заметьте трёхтактовый разворот: 1 → 3 → 2. Первый раунд — следовали скиллу буквально. Второй — критика про операционность пушила вверх. Третий — критика про реализуемость придавила обратно. Скилл получил ещё одно правило: сначала проверь, можешь ли ты режим воспроизвести в тесте; только потом записывай в контракт.
Тимлид-ревью перед хендоффом
Контракт зафиксирован, можно передавать sonnet'у писать .feature-файлы. Перед хендоффом — пауза.
Промпт 6 (opus). Посмотри критически как тимлид с 20-летним стажем: всего хватает, чтобы передать управление sonnet на разработку тестов?
Опус-в-роли-тимлида прошёлся по чек-листу. Пять блокеров:
-
Не выбран тест-раннер и формат степ-дефиниций. Sonnet написал бы
.featureв воздухе. На Шаге 3 окажется, что половина степов нереализуема в выбранном раннере. -
Не определена WebAuthn-фикстура. «Валидная подпись» без фикстуры — пустая абстракция. Без библиотеки виртуального аутентификатора шаг
клиент собирает attestationозначает что-то разное в каждой реализации. -
Не определено, как ставить SQLite в каждый из режимов отказа. Шаг
Given БД заблокирована— этоBEGIN EXCLUSIVEот runner-процесса? Sidecar-контейнер с локером? Test-endpoint в SUT? Без выбора sonnet выдумал бы три разных подхода в трёх файлах. -
Не описан чейнинг токенов и id из phase 1 в phase 2. Двухфазный протокол требует прокидки challenge id между шагами. Без явного механизма sonnet каждый раз писал бы по-новому.
-
Постусловие «не сохранено» при
db_disk_fullнепроверяемо без сброса состояния. БД-то в режиме disk-full — как проверить «не сохранено»?
Вердикт: не отпускаем sonnet'а на пустоту. Нужен Шаг 2.0 — построить шаблон компонентных тестов с раннером, фикстурами и базовыми степами.
Шаг 2.0 — шаблон тестов в Docker
Промпт 7 (opus). Нужно добавить в бэклог — создать шаблон компонентных тестов на go c docker-compose. Вот аналог тест-раннера и структура compose на JVM, сможешь сделать такой же на Go?
Аналог — codemonstersteam/mq-rest-sync-adapter/component-tests: Cucumber + Gradle + Docker Compose + WireMock + IBM MQ. Перенос на Go: godog вместо Cucumber, go.mod вместо Gradle, остальное — структурно тоже.
Ключевое отличие: SQLite встроенная — нет внешних контейнеров-стабов. «Стабы» сводятся к параметризации SQLite-mount через env-профили compose-файла. Один docker-compose.test.yml с базовым стеком + override для disk-full профиля.
Промпт 8 (opus). (godog проще запускать с хоста через
go test). Нужно в докере запускать, чтобы была полная изоляция раннера.
Я предложил практичное «запускать с хоста — это быстрее» и получил возражение. Раннер в контейнере, без компромиссов:
- окружение в CI и локально — одно и то же;
- раннер не зависит от Go-toolchain хоста;
- сеть между раннером и сервисом — внутренняя docker-сеть, не loopback;
- отказ воспроизводится одинаково везде.
go test ./steps/... с хоста — никогда. Только ./scripts/run-tests.sh через docker compose run runner. Это закрывает класс багов «у меня работает, в CI падает».
Семь вопросов по архитектуре раннера
Шаблон — это десяток конфиг-файлов, два Dockerfile, шесть Go-файлов со степами. На каждый артефакт — несколько архитектурных развилок. Я мог бы катить решения волевым порядком и потом расхлёбывать. Вместо этого:
Промпт 9 (opus). Что мне нужно провалидировать?
Промпт 10 (opus). Задай вопросы по одному.
Вопросы по одному — критическая дисциплина. Если задать семь вопросов одним списком, человек ответит на первые три и устанет. Если по одному — каждый ответ продуман.
Вот они:
| # | Вопрос | Ответ | Почему |
|---|---|---|---|
| Q1 | Версия Go | 1.26 | Установлена у разработчика, актуальная стабильная |
| Q2 | Один или два модуля | Два (/go.mod + /component-tests/go.mod) |
Тест-зависимости (godog, virtualwebauthn, mattn/go-sqlite3) не пачкают прод-go.mod. Соответствует JVM-аналогу |
| Q3 | Драйвер SQLite | mattn/go-sqlite3 |
Стандарт де-факто, важно для обучающего материала. Цена — CGO + alpine runtime вместо distroless/static |
| Q4 | Поведение placeholder | 501 Not Implemented на всё, кроме /health (200) |
Чёткая граница «эндпоинт не реализован», не путается с реальным 401 (нет токена) в Шаге 3 |
| Q5 | Как ставить db_locked |
Раннер шарит volume с SUT, открывает свой коннект, делает BEGIN EXCLUSIVE |
Прямой путь, никаких sidecar/test-endpoint |
| Q6 | HEALTHCHECK SUT | В service.Dockerfile + depends_on: condition: service_healthy |
Стандартный compose-флоу. После Q3 (alpine) — wget доступен |
| Q7 | Структура compose-файлов | Один базовый + override (docker-compose.test.yml + docker-compose.disk-full.yml) |
Стандартный путь, видно явно, что меняется |
Каждый ответ продуман. Никаких «сделал на авось — потом перекатим».
Особенно важен Q3. mattn/go-sqlite3 требует CGO и C-тулчейна на сборке. Альтернатива (modernc.org/sqlite) — чистый Go, мельче образ, быстрее CI. Для demo разница в перфе не имеет значения. Для обучающего материала — наоборот, важно показать стандартный стек, который встретится в любом проде. Решение — mattn, цена — alpine-runtime вместо distroless/static. Это решение архитектурно влияет на Шаг 3: при написании модулей БД импорт пойдёт именно на mattn/go-sqlite3.
И Q4. Placeholder-сервис — это компромисс с принципом «контракт первичен». Без него smoke-тесту не во что стрелять. С ним — у нас есть HTTP-сервер, который отдаёт 501 на всё, и постепенно его хендлеры заменяются реальной логикой в Шаге 3. Выбор 501 вместо 401 — чёткая граница: «эндпоинт не реализован» против «токен не подходит». Мелочь, но сохранит часы в Шаге 3, когда часть эндпоинтов начнёт отдавать реальные ответы, а часть продолжит отвечать placeholder-кодом 501.
После всех Q opus построил шаблон в три этапа: каркас (go.mod, Dockerfiles, compose), степы (HTTP, WebAuthn через descope/virtualwebauthn, db_failure, auth-макрошаг), скрипты и smoke-сценарий. Каждый этап — закрытие нескольких тикетов в backlog.md сразу после реализации (правило, которое мы по ходу записали в AGENTS.md §10).
Smoke-сценарий — единственный .feature в Шаге 2.0:
# language: ru
Функционал: Smoke — проверка обвязки компонентных тестов
Сценарий: Placeholder-сервис отдаёт 501 на любой нереализованный эндпоинт
Когда клиент отправляет GET /v1/users/me
Тогда ответ 501
И ответ содержит JSON-поле code со значением "NOT_IMPLEMENTED"
Промпт 11 (opus). Запусти End-to-end smoke, я запустил докер.
Первый прогон — раннер падает с missing go.sum: я забыл скопировать go.sum в Dockerfile.runtime (оставил TODO с момента, когда go.sum ещё не было). Поправил — второй прогон зелёный:
Обвязка готова.
Шаг 2 — sonnet пишет .feature-файлы
После мержа Шага 2.0 в main эстафету подхватил sonnet. На входе — backlog.md с семью тикетами T2.1–T2.7, готовый шаблон, промпт-шаблон с явными ограничениями («используй существующие степы, не выдумывай новые формулировки — если не хватает, останови и сообщи»).
Что было в backlog.md (раздел «Шаг 2»):
### Шаг 2 — Компонентные тесты (Gherkin) — план для sonnet
Контекст: контракт зафиксирован, шаблон тестов готов (Шаг 2.0 завершён).
OpenAPI содержит 503 db_locked и 507 db_disk_full; README — таблица
из двух режимов. Шаг 0 SKILL пройден заранее, sonnet сразу начинает
с Шага 1.
Ожидаемый итог: 4 файла, 8 сценариев = 6 happy-path + 2 отказа SQLite.
Запуск: Docker Compose с реальным SQLite, без моков.
Раскладка failure-сценариев по эндпоинтам:
db_locked → POST /v1/sessions/{id}/assertion (горячий путь записи)
db_disk_full → POST /v1/registrations/{id}/attestation (тяжёлая запись)
Раскладка по файлам:
component-tests/
├── registrations.feature 2 happy + db_disk_full
├── sessions.feature 2 happy + db_locked
├── sessions-current.feature 1 happy
└── users.feature 1 happy
Тикеты:
- [ ] T2.1 — registrations.feature: 3 сценария
(1) POST /v1/registrations с валидным handle
→ 201 {id, options}, challenge сохранён в БД
(2) POST /v1/registrations/{id}/attestation с валидным attestation
→ 200 TokenPair, пользователь и credential сохранены,
challenge помечен использованным
(3) db_disk_full на attestation
→ 507, error.code=db_disk_full
- [ ] T2.2 — sessions.feature: 3 сценария
(1) POST /v1/sessions с известным handle
→ 201 {id, options}, challenge сохранён
(2) POST /v1/sessions/{id}/assertion с валидной подписью
→ 200 TokenPair, сессия создана
(3) db_locked на assertion (SQLITE_BUSY)
→ 503 с заголовком Retry-After, error.code=db_locked
- [ ] T2.3 — sessions-current.feature: 1 сценарий
DELETE /v1/sessions/current с валидным Bearer-токеном
→ 204, refresh token инвалидирован в БД
- [ ] T2.4 — users.feature: 1 сценарий
GET /v1/users/me с валидным Bearer-токеном
→ 200 User, поля id и handle соответствуют
пользователю из токена
- [ ] T2.5 — Сверка. Прогнать чек-лист SKILL:
4 файла = 4 ресурса
6 happy-path = 6 эндпоинтов
2 сценария отказа = 2 режима
- [ ] T2.6 — Devlog. Зафиксировать в devlog/02-gherkin.md
по формату «Промпт / Что сделал агент / Решения / Результат»
- [ ] T2.7 — Merge в main
Хронология (видимая снаружи):
| Время | Событие |
|---|---|
| 17:06 | Sonnet начинает T2.1, сразу обнаруживает разрыв в WebAuthn-степах |
| 17:11 | PR #10 — registrations.feature, 3 сценария (T2.1) |
| 17:50 | PR #11 — sessions.feature, 3 сценария (T2.2) |
| 22:38 | PR #12 — sessions-current.feature + users.feature, 2 сценария (T2.3 + T2.4) |
| 22:45 | PR #13 — сверка чек-листа SKILL + devlog/02-gherkin.md (T2.5 + T2.6) |
| 22:47 | T2.7 — Шаг 2 закрыт |
Что обнаружил sonnet до того, как написал первую строчку Gherkin
Первый коммит sonnet — не .feature-файл, а fix к WebAuthn-степам:
fix(component-tests): WebAuthn-степы читают id из lastBody, новый степ непустого поля
Opus оставил степы вида:
С аргументом-id из Gherkin. Sonnet увидел, что UUID challenge-сессии генерируется сервером динамически на каждый прогон — в .feature-файл его статически не вписать. Поле phase1.ID уже парсилось в w.lastBody, но игнорировалось. Sonnet рефакторнул оба степа: ID читается из w.lastBody автоматически, аргумент убран:
Заодно sonnet добавил новый степ ответ содержит непустое JSON-поле <field> — для проверки динамических полей (JWT с меняющимся iat/exp, opaque refresh-token).
Это и есть тот самый «остановись и сообщи» из промпт-шаблона, только sonnet вместо «сообщить» сразу починил, потому что починка была локальная и понятная. Опус-в-ревью одобрил подход.
Решения, которые sonnet принял по ходу
Из его devlog:
Разные handle на каждый сценарий (alice / bob / carol). SQLite volume общий на весь прогон (не пересоздаётся между сценариями). Если все сценарии регистрируют одного пользователя — UNIQUE constraint ломает тесты после первого успешного. Простейшее решение без инфраструктурных изменений.
пользователь "X" зарегистрирован и залогинен как Given для sessions.feature. sendAssertion() требует w.credential и w.authenticator в World — они устанавливаются только через sendAttestation(). Единственный путь подготовить их через существующие степы — макрошаг userIsLoggedIn. Пользователь регистрируется как precondition; сам тест проверяет только sessions-контракт.
Для db_locked — лок ПОСЛЕ phase 1, ПЕРЕД phase 2. Phase 1 тоже пишет в БД (сохраняет challenge). Если заблокировать БД до phase 1, сценарий упадёт раньше нужного. Правильный порядок: phase 1 завершена → БД заблокирована → phase 2 проверяет 503.
Для db_disk_full — junk ПОСЛЕ phase 1, ПЕРЕД phase 2. Та же логика: phase 1 регистрации пишет challenge на диск. Junk заполняет место после phase 1, чтобы именно attestation (более тяжёлая запись: user + credential) получила SQLITE_FULL.
ответ 200 + непустое JSON-поле вместо точного значения JWT. Приватный ключ Ed25519 генерируется при старте процесса (не персистится), iat/exp меняется каждый раз — JWT принципиально динамичен. Новый степ responseJSONFieldNonEmpty проверяет присутствие токенов без проверки значения.
Каждое из этих решений — продуманный отклик на реальные ограничения раннера, не «как пришло в голову». Шаблон прошёл первое настоящее испытание.
Промпты sonnet-сессии
На вход пришли три слова: «начинаем T2.0.14 + T2.1». Никакого промпт-шаблона — только CLAUDE.md, AGENTS.md, backlog.md и то, что я прочитал сам. Перед каждым тикетом — явный список файлов для чтения из backlog: AGENTS.md, CLAUDE.md, SKILL.md, openapi.yaml, README раздел «Карта режимов отказа», component-tests/README.md, component-tests/HOW-TO.md, список степов в component-tests/steps/.
Где было трудно. Разрыв в WebAuthn-степах я поймал до того, как написал первую строчку Gherkin — именно потому, что прочитал степы до начала работы. Степ sendAttestation(challengeID string) принимал UUID аргументом из текста шага, но UUID генерируется сервером динамически: в feature-файл его не вписать. Поле phase1.ID уже парсилось из w.lastBody, но игнорировалось. Это была дыра в дизайне — opus при написании степов думал о вызове из Go (макрошаг userIsLoggedIn), а не из Gherkin.
Фикс vs остановиться-спросить. Я остановился и объяснил проблему, предложил минимальный фикс. Пользователь сказал «да» — я применил. Это правильная граница: рефакторинг аргумента функции — локальный и понятный; решение применять — не моё в одностороннем порядке. Другой случай: отсутствие степа ответ содержит непустое JSON-поле — здесь тоже остановился и спросил (JWT динамичен, значение не зафиксировать). Пользователь одобрил — добавил.
Что ощутил изнутри. Шаблон степов рассчитан на Go-вызовы (макрошаг), а не на Gherkin-строки с динамическими данными. Это системная проблема: когда opus проектировал степы, он тестировал их в голове через userIsLoggedIn, а не через .feature-файлы. Первый же сценарий выявил зазор.
Второй зазор — нет степа для «поле присутствует». responseJSONField требует точное значение, но JWT динамичен по природе. Этот степ должен был быть в шаблоне изначально: любой сервис с токенами столкнётся с той же задачей.
Что улучшить в шаблоне. Для следующего сервиса — добавить в шаблон степы responseJSONFieldNonEmpty и responseJSONFieldPresent сразу. И протестировать все WebAuthn-степы через .feature-файл (не только через Go-вызов) ещё в Шаге 2.0, до хендоффа.
Итог Шага 2
component-tests/features/
├── registrations.feature # 3 сценария: challenge, attestation, db_disk_full
├── sessions.feature # 3 сценария: challenge, assertion, db_locked
├── sessions-current.feature # 1 сценарий: DELETE /sessions/current → 204
└── users.feature # 1 сценарий: GET /users/me → 200 + handle
8 сценариев = 6 happy-path + 2 отказа SQLite. Формула SKILL: 6 эндпоинтов + 2 режима = 8. ✅
Smoke зелёный на каждом прогоне. Все 8 новых сценариев красные — placeholder отдаёт 501. Это и есть TDD-red фаза, точно как описано в SKILL: тесты ждут реализации в Шаге 3.
Статистика sonnet-сессии (/usage по завершении):
| Показатель | Значение |
|---|---|
| Чистое время генерации (без ожидания) | 15 минут 34 секунды |
| Wall-time (включая паузы) | 8 часов 59 минут |
| Стоимость | $4.07 |
| Output tokens | 52 300 |
| Cache read tokens | 7 800 000 |
| Cache write tokens | 251 300 |
| Строк кода: добавлено / удалено | 190 / 26 |
97% wall-time — паузы между сообщениями. Почти вся стоимость — cache read: длинные файлы (AGENTS.md, openapi.yaml, backlog.md) загружались из кэша на каждом ходу, а не перечитывались с нуля. Восемь сценариев контракта + два фикса степов — за 15 минут чистой генерации.
Уроки сессии
1. Скилл — рамка, не алгоритм
Скилл из статьи 04 не сработал «из коробки» с первого касания. Спека была пустая, формула давала число тестов на режимы, которых в контракте не существовало. Скилл нужно было прикручивать осознанно, проверяя предусловия каждого шага.
В скилл попали два новых правила:
- Шаг 0 расширен: прежде чем читать OpenAPI, проверь, что в нём есть 5xx с
error.code; если нет — спроектируй режимы отказа. - Правило различимости: режим — отдельный, если различается хотя бы одно из четырёх (HTTP, заголовки, действие клиента, действие оператора).
2. Контракт первичен — буквально
Если сценарий компонентного теста ссылается на error.code: db_locked, эта строка должна существовать в OpenAPI. Не «было сказано в статье», не «договорились в чате», не «реализуем в коде». Спека — единственный источник истины. Если её нет — сначала пишем, потом тестируем.
3. Только воспроизводимые режимы
В контракт идут режимы, которые реально воспроизводятся в тесте. db_unavailable для встроенной SQLite — синтетический, его невозможно поставить чисто, не ломая архитектуру сервиса. Спека не должна обещать клиенту того, что сервис не способен обещание выполнить детерминированно.
4. Тимлид-ревью обязателен перед хендоффом
ИИ-агент, который пишет код по контракту, нуждается в готовом окружении: раннер, фикстуры, базовые степы. Без них агент выдаст несовместимые формулировки в каждом файле. Опытный взгляд — на пять минут, экономия — на дни Шага 3.
5. Раннер — артефакт первого класса
Шаблон тестов на godog в Docker — это не «технические детали». Это решение, влияющее на каждый сценарий, на CI, на то, как разработчик видит проект. Раннер достоин Шага 2.0 — отдельной итерации до того, как начнётся написание .feature-файлов.
6. Q-by-Q дисциплина в архитектурных решениях
Семь вопросов от opus к человеку, по одному, с ясными альтернативами и аргументами. Никаких «сделал на авось». Это не бюрократия — это инвестиция: каждое решение продумано, объяснено и зафиксировано в devlog. Через год их можно перечитать и понять, почему всё именно так.
7. Backlog обновлять по тикетно
Правило, которое мы записали в AGENTS.md §10 прямо по ходу сессии: галочки [x] в backlog.md после каждого тикета, не одним батчем в конце PR. Backlog — единственный источник истины о статусе. Разрыв между ним и реальностью ломает план для следующей сессии и для других агентов.
8. Sonnet чинит локальное, останавливается на стратегическом
Шаблон не был идеальным — sonnet поймал две дыры в первый час: статический challenge id в WebAuthn-степе и отсутствие степа для непустого JSON-поля. Обе починил сам, не ожидая разрешения. И это было правильно — починки локальные, понятные, в духе шаблона. Останавливать его и спрашивать «можно ли поправить аргументы степа» — потеря времени с обеих сторон.
Линия проходит здесь: sonnet чинит то, что не меняет архитектурных границ; останавливается на том, что эти границы потенциально меняет. Если бы шаблону потребовался новый файл степов, или новый профиль compose, или поменялась семантика существующего шага — sonnet остановился бы. А «прочитать id из контекста вместо аргумента» — это не архитектура, это локальный рефакторинг внутри уже принятого подхода.
Резюме
| Артефакт | До сессии | После opus + sonnet |
|---|---|---|
| OpenAPI | без 5xx-ответов | 503 db_locked (с Retry-After) + 507 db_disk_full на write-эндпоинтах |
| README раздел «Карта режимов отказа» | одна строка без обоснования | двухстрочная таблица + блок «чем определяется набор режимов» с правилом различимости |
| Скилл | три источника режимов отказа, формула | + Шаг 0 «спроектируй и подготовь раннер», + правило различимости, + замечание про синтетические режимы |
| Шаблон компонентных тестов | отсутствовал | component-tests/ — отдельный модуль, godog в Docker, шесть файлов степов, smoke зелёный |
.feature-файлы |
отсутствовали | 4 файла, 8 сценариев — sonnet за 5,5 часов |
| Placeholder-сервис | отсутствовал | cmd/api/main.go — 501 на всё, /health 200; постепенно вытесняется в Шаге 3 |
AGENTS.md §10 |
без правила обновления backlog | + «обновляем после каждого тикета, не одним батчем в конце PR» |
После двух смежных сессий passkey-demo-api имеет красные компонентные тесты, готовые принять реализацию. Шаг 2 закрыт.
Что дальше
В следующей статье — Шаг 3: TDD-цикл по модулям, постепенное вытеснение placeholder в cmd/api/main.go реальной логикой. Каждый модуль — один цикл «красный тест → зелёный тест → рефакторинг». Sonnet или другой агент берёт модуль, пишет юниты, дописывает реализацию, проверяет, что соответствующий компонентный сценарий стал зелёным, мержит. И так до полной замены placeholder.
После Шага 3 — Шаг 4 (CI на PR через GitHub Actions): валидация OpenAPI, сборка, прогон компонентных тестов на каждом PR. Отдельная история про автоматизацию защиты от регрессий.
Источники
- Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.
- Morev M. Компонентные тесты на практике. Скилл для агента и разбор passkey-demo-api. codemonsters.team, 2026.
ubik-life/passkey-demo-api— учебный сервис, ветки сессии:feat/failure-modes-analysis,feat/component-tests-template,feat/gherkin-registrations,feat/gherkin-sessions,feat/gherkin-users-logout.codemonstersteam/mq-rest-sync-adapter/component-tests— JVM-аналог шаблона компонентных тестов.descope/virtualwebauthn— Go-библиотека виртуального WebAuthn-аутентификатора.cucumber/godog— Cucumber для Go.- SKILL.md — открытый стандарт для агентских скиллов (Anthropic).
- AGENTS.md — открытый стандарт для агентских инструкций (OpenAI / Linux Foundation).