Skip to content

Применение и доработка скилла компонентных тестов. Сессия с opus и хендофф sonnet'у

«Если карта не сходится с местностью — права местность.»

— старая военная пословица

В предыдущей статье мы зафиксировали скилл компонентных тестов как артефакт: положили SKILL.md рядом с проектом, написали процедуру, посчитали для passkey-demo-api по формуле семь сценариев. Всё красиво.

Реальность вмешалась на первой же минуте применения. Эта статья — журнал двух смежных сессий: opus прошёл от теории до зелёного smoke, потом sonnet за один день написал восемь сценариев по готовому шаблону. Карта (скилл) встретилась с местностью (реальный репозиторий), оба после этого изменились.

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

Как карта встретилась с местностью

Сессия началась короткой задачей:

Промпт 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-ответов».

$ grep -E "5[0-9][0-9]" api-specification/openapi.yaml
$

Пусто. Ни одного 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) или с длинной;
  • разработчик при разборе инцидента видит одинаковый код и идёт в логи, чтобы понять причину.

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

Правило различимости. Режим — отдельный, если различается хотя бы одно из четырёх:

  1. HTTP-статус (503 vs 507 vs 500);
  2. заголовки ответа (Retry-After есть/нет);
  3. действие клиента (ретраить / не ретраить / ретраить с backoff);
  4. действие оператора (ничего / 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 на разработку тестов?

Опус-в-роли-тимлида прошёлся по чек-листу. Пять блокеров:

  1. Не выбран тест-раннер и формат степ-дефиниций. Sonnet написал бы .feature в воздухе. На Шаге 3 окажется, что половина степов нереализуема в выбранном раннере.

  2. Не определена WebAuthn-фикстура. «Валидная подпись» без фикстуры — пустая абстракция. Без библиотеки виртуального аутентификатора шаг клиент собирает attestation означает что-то разное в каждой реализации.

  3. Не определено, как ставить SQLite в каждый из режимов отказа. Шаг Given БД заблокирована — это BEGIN EXCLUSIVE от runner-процесса? Sidecar-контейнер с локером? Test-endpoint в SUT? Без выбора sonnet выдумал бы три разных подхода в трёх файлах.

  4. Не описан чейнинг токенов и id из phase 1 в phase 2. Двухфазный протокол требует прокидки challenge id между шагами. Без явного механизма sonnet каждый раз писал бы по-новому.

  5. Постусловие «не сохранено» при 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 ещё не было). Поправил — второй прогон зелёный:

1 scenarios (1 passed), 3 steps (3 passed)

Обвязка готова.

Шаг 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 оставил степы вида:

Когда клиент собирает attestation для challenge с id "abc-123" и отправляет его

С аргументом-id из Gherkin. Sonnet увидел, что UUID challenge-сессии генерируется сервером динамически на каждый прогон — в .feature-файл его статически не вписать. Поле phase1.ID уже парсилось в w.lastBody, но игнорировалось. Sonnet рефакторнул оба степа: ID читается из w.lastBody автоматически, аргумент убран:

Когда клиент собирает attestation и отправляет его

Заодно 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. Отдельная история про автоматизацию защиты от регрессий.

Источники

  1. Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.
  2. Morev M. Компонентные тесты на практике. Скилл для агента и разбор passkey-demo-api. codemonsters.team, 2026.
  3. ubik-life/passkey-demo-api — учебный сервис, ветки сессии: feat/failure-modes-analysis, feat/component-tests-template, feat/gherkin-registrations, feat/gherkin-sessions, feat/gherkin-users-logout.
  4. codemonstersteam/mq-rest-sync-adapter/component-tests — JVM-аналог шаблона компонентных тестов.
  5. descope/virtualwebauthn — Go-библиотека виртуального WebAuthn-аутентификатора.
  6. cucumber/godog — Cucumber для Go.
  7. SKILL.md — открытый стандарт для агентских скиллов (Anthropic).
  8. AGENTS.md — открытый стандарт для агентских инструкций (OpenAI / Linux Foundation).