Проверка: тесты из спецификации и ревью
Как убедиться, что агент сделал то, что написано в спецификации: приёмочные тесты pytest из сценариев «Дано / Когда / Тогда», матрица трассируемости «требование → сценарий → тест», тесты сначала при работе с агентом, мутационное тестирование с mutmut, ревью кода агента по чек-листу, дрейф спецификации и кода и метрики процесса.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Спецификация без проверки — пожелание. В конце задачи агент пишет «Готово, все тесты проходят», и это часто правда. Только тесты он написал сам, по своему пониманию задачи. Если он понял правило 7 суток как «больше семи», тест проверит «больше семи» и тоже пройдёт.
У проверки в SDD четыре вопроса, и на каждый в уроке есть свой инструмент:
| Вопрос | Инструмент | Кто делает |
|---|---|---|
| Поведение соответствует сценариям? | приёмочные тесты из сценариев | агент пишет, человек сверяет со сценариями |
| У каждого ли требования есть тест? | матрица трассируемости | скрипт |
| Тесты вообще ловят ошибки? | мутационное тестирование | инструмент (mutmut) и человек |
| В коде нет лишнего и опасного? | ревью по чек-листу | человек, субагент помогает |
Пятый вопрос растянут во времени: остаются ли спецификация и код согласованными через месяц, после срочных правок и новых фич? Это дрейф и сведение — в конце урока. Работаем с тем же проектом записи на поток, что в уроке SDD.4.1.
Из сценария — в приёмочный тест
Заголовок раздела «Из сценария — в приёмочный тест»Приёмочный тестприёмочный тестEN acceptance testАвтоматический тест, который проверяет поведение системы по критерию приёмки — обычно по сценарию «Дано / Когда / Тогда» с теми же данными. В курсе — один тест на сценарий, ID сценария в имени: test_sc_006_full_refund_exactly_7_days. проверяет поведение по критерию приёмкикритерий приёмкиEN acceptance criterionПроверяемое условие, при котором требование или история считаются выполненными. Удобная форма — сценарий «Дано / Когда / Тогда» с конкретными данными: из него прямо пишется приёмочный тест.. В курсе это значит: по сценарию «Дано / Когда / Тогда» с теми же данными. Перевод почти механический:
| Часть сценария | В тесте | Пример для SC-006 |
|---|---|---|
| общие данные спецификации | константы и фикстура | поток со стартом 2 ноября 2026 в 10:00, 2 места, 30 000 руб. |
| Дано | подготовка: фикстура, помощник | у Анны подтверждённая запись |
| Когда | одно действие | cohort.cancel(anna, datetime(2026, 10, 26, 10, 0)) |
| Тогда | assert на наблюдаемый результат | возврат Money(3_000_000, "RUB") |
| ID и название | имя теста и докстринг | test_sc_006_full_refund_exactly_7_days, «SC-006: …» |
Так выглядят тесты сценариев отмены из спецификации урока SDD.2.1 (с уточнениями урока SDD.4.1). Код модели — в конце практики урока SDD.4.1:
"""Приёмочные тесты по сценариям specs/001-enrollment/spec.md: один тест — один сценарий."""from datetime import datetime
import pytest
from enrollment import (AlreadyEnrolled, Cohort, EnrollmentCancelled, EnrollmentClosed, EnrollmentStatus, InvalidStatusTransition, Money, NoFreeSeats)
STARTS_AT = datetime(2026, 11, 2, 10, 0) # общие данные сценариевPRICE = Money(3_000_000, "RUB") # 30 000 руб. в копейках
@pytest.fixturedef cohort(): """Поток «Python для аналитиков»: старт 2 ноября 2026 в 10:00, 2 места, 30 000 руб.""" return Cohort("python-2026-11", STARTS_AT, capacity=2, price=PRICE)
def confirmed(cohort, student_id): """Дано: у студента запись со статусом «подтверждена».""" enrollment_id = cohort.enroll(student_id, datetime(2026, 10, 1, 10, 0)) cohort.confirm(enrollment_id, datetime(2026, 10, 1, 11, 0)) return enrollment_id
def test_sc_005_unpaid_cancelled_after_exactly_48_hours(cohort): """SC-005: Ровно 48 часов без оплаты (REQ-005, REQ-008).""" anna = cohort.enroll("anna", datetime(2026, 10, 1, 10, 0)) # Дано cohort.expire_unpaid(datetime(2026, 10, 3, 10, 0)) # Когда assert cohort.status_of(anna) == EnrollmentStatus.CANCELLED # Тогда [event] = cohort.pull_events() # И событие assert isinstance(event, EnrollmentCancelled) and event.enrollment_id == anna
def test_sc_006_full_refund_exactly_7_days(cohort): """SC-006: Отмена ровно за 7 суток — полный возврат (REQ-006, REQ-009).""" anna = confirmed(cohort, "anna") refund = cohort.cancel(anna, datetime(2026, 10, 26, 10, 0)) assert refund == Money(3_000_000, "RUB")
def test_sc_007_no_refund_one_minute_later(cohort): """SC-007: Отмена на минуту позже — без возврата (REQ-007).""" anna = confirmed(cohort, "anna") refund = cohort.cancel(anna, datetime(2026, 10, 26, 10, 1)) assert cohort.status_of(anna) == EnrollmentStatus.CANCELLED assert refund == Money(0, "RUB")
def test_sc_010_no_refund_for_unpaid_enrollment(cohort): """SC-010: Отмена неоплаченной записи — без возврата (REQ-006).""" anna = cohort.enroll("anna", datetime(2026, 10, 1, 10, 0)) refund = cohort.cancel(anna, datetime(2026, 10, 1, 12, 0)) assert refund == Money(0, "RUB")
# Тесты SC-001…SC-004, SC-008 и SC-011 устроены так же: по одному на сценарий.Запуск из корня проекта. python -m pytest добавляет текущую папку в путь поиска модулей, поэтому enrollment
импортируется без настроек:
$ python -m pytest tests/acceptance -vtests/acceptance/test_enrollment.py::test_sc_001_enroll_when_seat_is_free PASSED [ 10%]tests/acceptance/test_enrollment.py::test_sc_002_no_free_seats PASSED [ 20%]...tests/acceptance/test_enrollment.py::test_sc_006_full_refund_exactly_7_days PASSED [ 60%]tests/acceptance/test_enrollment.py::test_sc_007_no_refund_one_minute_later PASSED [ 70%]...tests/acceptance/test_enrollment.py::test_sc_011_second_cancel_is_refused PASSED [100%]============================== 10 passed in 0.02s ==============================Отчёт pytest читается как список правил. Если упадёт test_sc_006_…, сразу видно, какое правило нарушено и где
его текст. Правила хорошего приёмочного теста:
- Данные из сценария, буква в букву. Те же даты, суммы, имена. «Улучшать» пример нельзя: граница в 10:00 и 10:01 — смысл сценария, а не случайность.
- Одно действие в «Когда». Два действия — два сценария и два теста.
- Наблюдаемый результат в «Тогда». Статус, сумма, исключение, событие. Не приватное поле
_enrollments: такой тест сломается при рефакторинге, хотя поведение не изменилось. - Время только параметром. Никаких
datetime.now()иsleep: тест SC-005 «проживает» 48 часов за микросекунду. - Один тест — один сценарий. Удобно для трассировки. Если сценарию нужны вариации данных, подойдёт
pytest.mark.parametrize, но ID в имени сохраняется.
Если сценарии хранят в файлах .feature на языке Gherkin, их исполняют напрямую инструменты BDD для Python,
например pytest-bdd или behave. В курсе мы обходимся без них. Сценарий живёт в spec.md, а связь держится на ID в
имени теста: это проще, и агенту не нужен ещё один формат.
Трассируемость: от требования до кода
Заголовок раздела «Трассируемость: от требования до кода»ТрассируемостьтрассируемостьEN traceabilityВозможность пройти по ссылкам от требования к сценарию, задаче, тесту и коду и обратно. Держится на идентификаторах (REQ-001, SC-001, T-001) и позволяет автоматически найти требования без задач и задачи без требований. — возможность пройти по ссылкам от требования до кода и обратно. Цепочка в нашем проекте держится на идентификаторах:
flowchart LR R["REQ-006<br/>spec.md"] --> S1["SC-006"] R --> S2["SC-010"] S1 --> T1["test_sc_006_…"] S2 --> T2["test_sc_010_…"] R --> K["T-004<br/>tasks.md"] K --> C["коммит «T-004: …<br/>(REQ-006, …)»"] T1 --> M["Cohort.cancel<br/>enrollment/model.py"] T2 --> M C --> M
Ссылки живут в пяти местах: строка «Требования:» у сценария, имя теста, поля задачи в tasks.md, сообщение коммита
и комментарий # REQ-006 в коде. Последние два — для людей. Первые три можно проверить программой, и из них
строится матрица трассируемостиматрица трассируемостиEN traceability matrixТаблица «требование → сценарии → тесты» (иногда ещё задачи и код). Показывает требования без тестов, сценарии без тестов и тесты, которые ссылаются на несуществующие сценарии; из неё считают долю требований, покрытых тестами.. Вот она для нашего проекта (код, который её
считает, вы напишете в практике):
Покрытие требований тестами: 90.9% REQ-001 SC-001, SC-008 test_sc_001_enroll_when_seat_is_free, test_sc_008_enroll_again_after_cancel REQ-002 SC-002 test_sc_002_no_free_seats REQ-003 SC-003, SC-008 test_sc_003_second_enrollment_is_refused, test_sc_008_enroll_again_after_cancel REQ-004 SC-004 test_sc_004_enrollment_closes_at_start REQ-005 SC-005 test_sc_005_unpaid_cancelled_after_exactly_48_hours REQ-006 SC-006, SC-010 test_sc_006_full_refund_exactly_7_days, test_sc_010_no_refund_for_unpaid_enrollment REQ-007 SC-007 test_sc_007_no_refund_one_minute_later REQ-008 SC-005 test_sc_005_unpaid_cancelled_after_exactly_48_hours REQ-011 SC-011 test_sc_011_second_cancel_is_refused REQ-009 SC-006 test_sc_006_full_refund_exactly_7_days REQ-010 SC-009 —Требования без тестов: REQ-010Сценарии без тестов: SC-009Сиротские тесты: нетТесты без ID сценария: test_money_is_frozenКак читать отчёт:
- Требование без тестов (REQ-010, время ответа под нагрузкой) — не обязательно ошибка. Нефункциональное требование проверяют нагрузочным тестом в отдельном наборе, а не модульным тестом за 0,02 с. Важно, что пробел виден и осознан, а не прячется. Покрытие 90,9 % здесь честнее, чем «100 %» с тестом-пустышкой.
- Сиротский тест (сиротский тестсиротский тестEN orphan testТест, который ссылается на сценарий или требование, которых в спецификации уже нет. Обычно признак дрейфа: спецификацию поменяли, а тест остался — или наоборот, код написали без спецификации.) ссылается на сценарий, которого в спецификации нет. Например,
test_sc_013_offer_seat_from_waitlist: фичу листа ожидания вынесли в отдельную спецификацию, а тест остался. Это первый признак дрейфа, о нём ниже. - Тест без ID — обычно модульный тест (
test_money_is_frozen). Это нормально, но в отчёте их видно, и тест «с поведением», случайно оставшийся без ID, легко заметить. - Требование, покрытое только «по пути», как REQ-008 (событие отмены): сценарий SC-005 про 48 часов заодно проверяет событие. Связь формально есть, но проверка слабая. Насколько слабая, покажет мутационное тестирование.
В матрицу можно добавить столбец задач: в tasks.md у каждой задачи есть строка «Требования:» (формат урока
SDD.2.3, граф задач вы разбирали в задании sdd/task-graph).
А линтер требований из урока SDD.2.1 (задание sdd/requirements-linter) до матрицы ловит требования без
сценариев и сценарии со ссылками в никуда. Вместе три скрипта проверяют всю цепочку: требование → сценарий →
задача → тест.
Тесты сначала: TDD с агентом
Заголовок раздела «Тесты сначала: TDD с агентом»Разработка через тестированиеразработка через тестирование (TDD)EN Test-Driven DevelopmentПрактика Кента Бека: сначала падающий тест, потом минимальный код, чтобы он прошёл, потом рефакторинг («красный — зелёный — рефакторинг»). Тесты становятся исполняемой спецификацией. — красный тест, минимальный код до зелёного, рефакторинг. С агентом у неё появляется новая цель. Тест, написанный до кода, — это исполняемая спецификация. Её дешевле проверить человеку, чем код: четыре теста по 6 строк против 60 строк логики. Поэтому разумно делить задачу на два шага с контрольной точкой между ними:
Задача T-004, шаг 1 из 2: только тесты. Напиши в tests/acceptance/test_enrollment.py тестыtest_sc_006_…, test_sc_007_…, test_sc_010_…, test_sc_011_… строго по сценариям spec.md:те же даты, суммы и статусы. Код в enrollment/ не трогай. Запусти тесты и покажи,что они падают, и почему именно.Пока агент ждёт, вы сверяете тесты со сценариями: те же ли данные, проверяет ли «Тогда» то, что написано в сценарии. Потом — шаг 2, код. Три тонкости:
- Падать надо по правильной причине.
AttributeError: 'Cohort' object has no attribute 'cancel'до реализации — правильно. Ошибка импорта из-за опечатки или упавшая фикстура — тест ещё ничего не доказал. - Тесты замораживаются. На шаге 2
git diff --stat tests/должен быть пустым. Если агент поменял тест, чтобы код прошёл, это тот самый уход со спецификации из урока SDD.4.1. Можно запретить правку и механически: хукPreToolUseв Claude Code умеет блокировать изменение защищённых файлов (пример «Block edits to protected files» — в документации по хукам). - Одно понимание — две ошибки. Если агент неверно понял правило, он ошибётся одинаково и в тесте, и в коде, и всё будет зелёным. Против этого работают данные из сценария буква в букву, ваше ревью тестов на шаге 1 и, для важных фич, тесты и код из разных сессий.
Если вы работаете через Spec Kit, помните: шаблон задач по умолчанию считает тесты необязательными («Tests are OPTIONAL») и добавляет их, только если их требуют конституция или спецификация (урок SDD.3.1).
Мутационное тестирование: кто проверяет тесты
Заголовок раздела «Мутационное тестирование: кто проверяет тесты»Тесты зелёные, покрытие требований 90,9 %. Но поймают ли тесты ошибку? Мутационное
тестированиемутационное тестированиеEN mutation testingПроверка самих тестов: инструмент вносит в код маленькие искажения (мутанты) — >= на >, and на or, 48 на 49 — и запускает тесты. Тесты упали — мутант «убит»; прошли — «выжил», значит, такую ошибку тесты не заметят. Для Python — mutmut. отвечает на этот вопрос экспериментом. Инструмент вносит в код маленькую ошибку, она называется
мутант: >= на >, and на or, 48 на 49. Потом он запускает тесты. Упали — мутант убит, тесты ошибку
заметили. Прошли — мутант выжил: такая ошибка пройдёт в прод незамеченной. Доля
убитых мутантовдоля убитых мутантовEN mutation scoreОтношение числа убитых мутантов к числу всех проверенных. В отличие от покрытия строк показывает не то, какой код выполнялся в тестах, а то, какие ошибки в нём тесты действительно поймают. честнее покрытия строк. Строка с >= может выполняться во всех тестах, и ни один из них не
заметит замену на >.
Вручную: семь мутантов
Заголовок раздела «Вручную: семь мутантов»Идея помещается в сорок строк. Скрипт копирует проект во временную папку, портит одну строку и запускает тесты:
"""Мутационное тестирование вручную: портим код по одной правке и смотрим, заметят ли тесты."""import pathlibimport shutilimport subprocessimport sysimport tempfile
MUTANTS = [ # (что меняем, было, стало) ("граница 7 суток: >= → >", "now >= FULL_REFUND_PERIOD", "now > FULL_REFUND_PERIOD"), ("граница 48 часов: >= → >", "enrolled_at >= PAYMENT_PERIOD", "enrolled_at > PAYMENT_PERIOD"), ("старт потока: >= → >", "if now >= self.starts_at", "if now > self.starts_at"), ("мест нет: <= 0 → < 0", "self.free_seats <= 0", "self.free_seats < 0"), ("48 часов → 49 часов", "timedelta(hours=48)", "timedelta(hours=49)"), ("возврат без проверки оплаты", "if paid and self.starts_at", "if self.starts_at"), ("отменённая запись мешает записаться", "student_id and e.takes_seat", "student_id"),]
def run_tests(project, extra_args): """True, если тесты прошли (мутант выжил), False — если упали (мутант убит).""" cmd = [sys.executable, "-m", "pytest", "-q", "-x", "-p", "no:cacheprovider", "tests/acceptance", *extra_args] return subprocess.run(cmd, cwd=project, capture_output=True).returncode == 0
def main(extra_args): source = pathlib.Path("enrollment/model.py").read_text(encoding="utf-8") killed = 0 for title, old, new in MUTANTS: assert source.count(old) == 1, f"фрагмент «{old}» должен встречаться ровно один раз" with tempfile.TemporaryDirectory() as tmp: shutil.copytree(".", tmp, dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__")) pathlib.Path(tmp, "enrollment/model.py").write_text(source.replace(old, new), encoding="utf-8") survived = run_tests(tmp, extra_args) killed += not survived print(f"{'ВЫЖИЛ' if survived else 'убит '} {title}") print(f"Мутантов убито: {killed} из {len(MUTANTS)} ({killed / len(MUTANTS):.0%})")
if __name__ == "__main__": main(sys.argv[1:]) # дополнительные аргументы уходят в pytest, например -k "not sc_010"Сохраните как tools/mutate.py и запустите из корня проекта дважды: со всеми тестами и без двух сценариев,
которые добавились на уточнениях в уроке SDD.4.1:
$ python tools/mutate.pyубит граница 7 суток: >= → >...убит возврат без проверки оплатыубит отменённая запись мешает записатьсяМутантов убито: 7 из 7 (100%)
$ python tools/mutate.py -k "not sc_008 and not sc_010"убит граница 7 суток: >= → >убит граница 48 часов: >= → >убит старт потока: >= → >убит мест нет: <= 0 → < 0убит 48 часов → 49 часовВЫЖИЛ возврат без проверки оплатыВЫЖИЛ отменённая запись мешает записатьсяМутантов убито: 5 из 7 (71%)Граничные сценарии SC-005, SC-006 и SC-004 убивают мутантов >= → >. Без SC-008 и SC-010 выживают ровно те
ошибки, которые закрывают эти два сценария: SC-008 записан после решения экспертов о повторной записи, SC-010 —
после вопроса агента на уточнениях. Вопрос, превращённый в сценарий, стал тестом, который ловит
реальную ошибку.
Инструмент: mutmut
Заголовок раздела «Инструмент: mutmut»Вручную мутантов придумываете вы, и придумываете те, о которых уже подумали. Инструмент перебирает их
систематически. Для Python есть mutmut (github.com/boxed/mutmut, лицензия BSD). Ему нужен системный вызов fork,
поэтому на Windows он работает только внутри WSL. Мутантов он гоняет в копии проекта в папке mutants/, а команда
mutmut apply записывает выбранного мутанта прямо в ваш файл — перед ней всё должно быть закоммичено, README
предупреждает об этом отдельно. Установка и настройка — в виртуальном окружении проекта:
pip install mutmutprintf '[mutmut]\nsource_paths=enrollment/\npytest_add_cli_args_test_selection=tests/\n' > setup.cfgmutmut run # мутирует код из source_paths и гоняет тесты; результаты — в папке mutants/mutmut results # список выжившихmutmut show "<имя мутанта>" # дифф конкретного мутантаНа модели из урока SDD.4.1 и десяти приёмочных тестах mutmut 3.8.0 создал 75 мутантов. Итоговая строка прогресса (🎉 — убиты, 🙁 — выжили):
75/75 🎉 56 🫥 0 ⏰ 0 🤔 0 🙁 19 🔇 0 🧙 0Доля убитых — 56 из 75, около 75 %. Самое интересное — разбор 19 выживших. Вот один из них:
# enrollment.model.xǁCohortǁexpire_unpaid__mutmut_1: survived for enrollment in self._enrollments.values(): if enrollment.status == EnrollmentStatus.PENDING_PAYMENT and now - enrollment.enrolled_at >= PAYMENT_PERIOD: if enrollment.status == EnrollmentStatus.PENDING_PAYMENT or now - enrollment.enrolled_at >= PAYMENT_PERIOD:С or таймер отменил бы оплаченную запись через 48 часов, а тесты этого не замечают. В спецификации просто нет
сценария «подтверждённая запись через 48 часов остаётся». Выжившие разбиваются на четыре группы, и у каждой своё
решение:
| Группа | Сколько | Пример мутанта | Что это значит | Решение |
|---|---|---|---|---|
| логика условия | 1 | and → or в expire_unpaid | нет сценария на важный случай | новый сценарий SC-012 в спецификацию, потом тест |
| содержимое события | 11 | refund или момент в EnrollmentCancelled заменены на None | REQ-008 говорит «опубликовать событие», но не что в нём | уточнить REQ-008 и шаги «Тогда» в SC-005, SC-006: сумма и момент |
| тексты исключений | 5 | сообщение NoFreeSeats заменено на None | тексты не специфицированы | осознанно оставить: тесты на формулировки хрупкие |
| формат номера записи | 2 | …-{len + 1} → …-{len + 2} | внутренний идентификатор, правил о нём нет | оставить |
После правок спецификации тест SC-005 проверяет событие целиком:
assert cohort.pull_events() == [EnrollmentCancelled( "python-2026-11", anna, "anna", refund=Money(0, "RUB"), occurred_at=datetime(2026, 10, 3, 10, 0))]В SC-006 добавлена такая же проверка события с возвратом, и появился тест SC-012. Новый прогон: 68 из 75, около 91 %. Выжили 7 мутантов из двух последних групп — их мы оставили сознательно.
Главный урок здесь не в процентах. Выжившие мутанты указали на два места, где спецификация была неполной. Мы исправили сначала её и только потом тесты. Мутационное тестирование дорогое: каждый мутант — полный прогон тестов. Запускайте его на доменном модуле, перед релизом важной фичи или по расписанию, а не на каждую правку. И не гонитесь за 100 %: часть мутантов эквивалентна исходному коду или не важна.
Ревью кода агента по чек-листу
Заголовок раздела «Ревью кода агента по чек-листу»Тесты, матрица и мутации проверяют то, что описано в спецификации. Ревью ловит то, чего в ней нет: лишний код,
опасные вызовы, новые зависимости. Общие правила код-ревьюкод-ревьюEN code reviewПроверка изменений кода другим инженером до слияния в основную ветку — на корректность, читаемость, тесты и производительность. разобраны в
уроке 11.1.2. Для кода агента у них свои акценты. Порядок: сначала
git diff --stat, потом тесты, потом код.
| Пункт | Как проверить | Типичная находка в коде агента |
|---|---|---|
| Соответствие спецификации | каждое изменение поведения указывает на REQ задачи | правило реализовано «по смыслу»: > вместо >=, округление вместо точного сравнения |
| Лишний код | всё, чего нет в спецификации и плане, — под вопросом | «гибкая» настройка, кэш, повторные попытки, неиспользуемые параметры «на будущее» |
| Тесты | git diff --stat tests/; нет skip, xfail, закомментированных assert | ослабленная проверка в тесте, чтобы код прошёл |
| Единый язык | имена из глоссария; прогнать линтер языка из урока DDD.1.2 | Booking, OrderStatus, process_data |
| Ошибки | доменные исключения по спецификации | except Exception: pass, возврат None вместо отказа |
| Время и деньги | нет чтения часов в модели, нет float для денег | datetime.now() в методе, price * 0.5 |
| Безопасность | секреты, ввод от пользователя, внешние вызовы | ключ API в коде или в логе, eval, subprocess с shell=True и данными пользователя, SQL склейкой строк, verify=False |
| Зависимости | каждый новый пакет есть в плане; проверить, что он существует, кто автор, какая лицензия | пакет «для удобства» на три строки кода; имя пакета, которого нет в реестре |
Последний пункт стоит пояснить. Модели иногда «вспоминают» правдоподобные имена пакетов, которых не существует.
Злоумышленники регистрируют такие имена в публичных реестрах, чтобы под видом нужной библиотеки установился их
код (захват выдуманных имён пакетовзахват выдуманных имён пакетов (slopsquatting)EN slopsquattingАтака на цепочку поставок, о которой заговорили в 2025 году: языковые модели иногда предлагают правдоподобные, но несуществующие имена пакетов, а злоумышленник регистрирует такие имена в публичном реестре с вредоносным кодом. Родственник typosquatting — захвата имён с опечатками., подробнее — в уроке SDD.4.3). Поэтому новая зависимость — решение человека (принцип 5 конституции): сначала страница пакета, репозиторий и
лицензия, потом pip install.
Механическую часть чек-листа — соответствие, лишнее, имена, изменения тестов — дёшево отдать субагенту-ревьюеру из урока SDD.4.1. Безопасность и зависимости оставьте себе.
Дрейф спецификации и кода
Заголовок раздела «Дрейф спецификации и кода»Дрейфдрейф спецификации и кодаEN spec driftПостепенное расхождение спецификации и кода: срочную правку внесли только в код, агент принял решение, которого нет в документах, требование изменили, а реализацию нет. Ловится матрицей трассируемости, приёмочными тестами и ревью; лечится сведением. — постепенное расхождение спецификации и кода. Никто не решает «пусть разойдутся», это происходит само:
- срочная правка только в коде: поддержка попросила вернуть деньги студенту, отменившему запись за 6 суток, и кто-то поменял 7 на 6 «на время»;
- решение агента, которого нет в документах: округление, порядок проверок, поведение на пустом входе;
- изменение только в спецификации: методисты добавили требование, а задачу на него никто не завёл;
- переименования: сценарий SC-013 перенесли в другую спецификацию, а тест
test_sc_013_…остался.
Как заметить дрейф:
-
Приёмочные тесты краснеют, если код ушёл от сценария. Но только если сами тесты следуют спецификации. Поэтому правка «7 на 6» вместе с правкой теста — самый опасный вид дрейфа.
-
Матрица трассируемости показывает сиротские тесты и требования без тестов.
-
Коммиты без ID задач. У нас каждый коммит начинается с
T-NNN, так что выпадающие видны одной командой:$ git log --format='%h %s' | grep -v -E 'T-[0-9]{3}'8dec2a5 срочно: возврат за 6 дней по просьбе поддержки -
Сверка инструментом. В Spec Kit это сведениесведение спецификации и кодаEN convergence (converge)Шаг, на котором код сверяют со спецификацией, планом и задачами и закрывают расхождения: недоделанное — новыми задачами, ошибки в документах — правкой документов. В Spec Kit это команда /speckit.converge: она только дописывает задачи в tasks.md, пока не сообщит «Converged».: команда
/speckit.converge(в Claude Code — навык/speckit-converge, появилась в версии 0.11.2). Она сверяет код соspec.md,plan.mdиtasks.mdи либо сообщает «Converged», либо дописывает недостающее задачами в раздел Convergence в концеtasks.md. Код она не правит. В OpenSpec формат изменений проверяетopenspec validate, а дельты в общую спецификацию детерминированно сливает только команда терминалаopenspec archive. Команды в чате/opsx:archiveи/opsx:syncделают то же силами агента. Подробно — в уроках SDD.3.1 и SDD.3.2.
Нашли расхождение — решите, кто прав. Это решает человек, иногда вместе с экспертом:
| Ситуация | Что правим | Как |
|---|---|---|
| код ошибся, спецификация верна | код | задача и тест на сценарий, если его не было |
| правило поменялось («теперь 6 суток») | сначала спецификацию | новое содержание REQ-006, сценарии на новую границу, тесты, код — одним коммитом |
| спецификация молчала, агент решил сам | спецификацию | записать решение: новое требование, пункт «Вне рамок» или ADR |
| сиротский тест | тест или спецификацию | сценарий удалён — удалить тест; сценарий переехал — поправить ID |
Какой документ после этого считается правдой, зависит от модели сохранения спецификаций. Living spec, flow-forward и flow-back из документации Spec Kit разобраны в уроке SDD.3.1. Правило для любой модели одно: изменение поведения не заканчивается, пока спецификация и код снова не говорят одно и то же.
Метрики
Заголовок раздела «Метрики»Метрики показывают, где процесс буксует. Считать их стоит по фиче или по спринту:
| Метрика | Как считать | Пример из урока | Что значит падение |
|---|---|---|---|
| доля требований с тестами | требования хотя бы с одним тестом / все требования | 10 из 11 = 90,9 % | требования пишутся, а сценарии и тесты — нет |
| доля убитых мутантов | убитые / все проверенные мутанты | 56 из 75 → 68 из 75 | тесты проверяют слабо, «Тогда» неполное |
| доля задач, принятых без правок | задачи, принятые на ревью с первого раза / все задачи | 3 из 5 = 60 % | задачи крупные или спецификация неясна |
| итераций на задачу | возвраты агенту на одну задачу | 1–3 | то же; растёт к концу сессии — пора начинать новую |
Доля задач, принятых без правок, — самая полезная метрика для самого процесса SDD. Если она низкая, проблема обычно не в агенте, а в спецификации или в размере задач. Записывайте причину каждой правки: «граница», «лишний код», «чужие имена». Через пару фич из этих записей вырастут правила для конституции и файла памяти.
И главное предупреждение. Метрика, ставшая целью, перестаёт измерять. Покрытие требований легко довести до 100 % тестами, которые ничего не проверяют, а долю принятых задач — ревью «по диагонали». Метрики — повод задать вопрос, а не оценка людей или агента.
Практика
Заголовок раздела «Практика»Напишите скрипт, который строит матрицу трассируемости. Он разбирает спецификацию в формате курса (тот же, что в линтере требований урока SDD.2.1) и имена тестов pytest. Нужно найти требования и сценарии без тестов, сиротские тесты, тесты без ID сценария и посчитать покрытие. В заготовке пример: спецификация записи на поток и пять тестов, среди которых один сиротский.
import re
REQ_ID = re.compile(r"REQ-\d{3}")
SC_ID = re.compile(r"SC-\d{3}")
# ID сценария в имени теста: sc_007, SC007, Sc_007 — граница слева: начало строки, «_» или не буква/цифра
SC_IN_NAME = re.compile(r"(?:^|[^a-z0-9])sc_?(\d{3})(?!\d)", re.I)
EXAMPLE_SPEC = """# Спецификация: запись на поток
## Требования
### Функциональные
- **REQ-001**: Когда студент записывается на поток со свободными местами до старта потока, система должна создать запись на поток со статусом «ожидает оплаты».
- **REQ-002**: Если число занимающих место записей равно вместимости потока, то система должна отказать в записи с ошибкой NoFreeSeats.
- **REQ-005**: Если запись на поток остаётся в статусе «ожидает оплаты» 48 часов с момента записи, то система должна перевести её в статус «отменена».
- **REQ-006**: Когда студент отменяет подтверждённую запись за 7 суток или более до старта потока, система должна вернуть ему полную стоимость.
### Нефункциональные
- **REQ-010**: Система должна отвечать на запрос записи на поток не дольше 500 мс для 95% запросов.
## Сценарии
### SC-001: Свободное место есть
Требования: REQ-001
- **Дано** на потоке одна запись Бориса со статусом «подтверждена»
- **Когда** Анна записывается на поток 1 октября в 10:00
- **Тогда** у Анны появляется запись со статусом «ожидает оплаты»
### SC-002: Мест нет
Требования: REQ-002
### SC-005: Ровно 48 часов без оплаты
Требования: REQ-005
### SC-006: Отмена ровно за 7 суток — полный возврат
Требования: REQ-006
### SC-009: Нагрузка
Требования: REQ-010
"""
EXAMPLE_TESTS = [
"tests/acceptance/test_enrollment.py::test_sc_001_enroll_when_seat_is_free",
"tests/acceptance/test_enrollment.py::test_sc_002_no_free_seats",
"tests/acceptance/test_enrollment.py::test_sc_006_full_refund_exactly_7_days",
"tests/acceptance/test_enrollment.py::test_sc_013_offer_seat_from_waitlist",
"tests/unit/test_money.py::test_money_is_immutable",
]
def parse_spec(text):
"""Разобрать спецификацию в markdown (формат курса — урок SDD.2.1).
Требования — пункты списка («- …» или «* …») в разделе «## Требования» (его подразделы «### …» тоже
считаются), текст пункта начинается с ID: «- **REQ-001**: …» или «- REQ-001: …». Упоминания REQ в
других разделах требованиями не считаются.
Сценарии — заголовки «### SC-001: …» (или «#### …») в разделе «## Сценарии»; их требования — ID из строки
«Требования: REQ-001, REQ-004» под заголовком. Нет такой строки — пустой список.
Раздел заканчивается на следующем заголовке «## …».
Возвращает (requirements, scenarios):
requirements — список ID требований в порядке появления, без повторов;
scenarios — словарь {"SC-001": ["REQ-001", "REQ-004"], ...} в порядке заголовков.
"""
# TODO: ваш код
...
def scenario_ids(test_name):
"""ID сценариев, упомянутых в имени теста pytest.
Берём только имя функции: после последнего «::» и без параметров в квадратных скобках.
ID записывается как «sc_007» или «sc007» в любом регистре; перед «sc» — начало имени или символ, который
не буква и не цифра («_»), после трёх цифр — не цифра. Используйте готовое выражение SC_IN_NAME.
Возвращает список вида ["SC-007", "SC-004"] в порядке появления, без повторов.
"""
# TODO: ваш код
...
def trace(spec_text, test_names):
"""Матрица трассируемости и отчёт о проблемах.
Тест относится к сценарию, если ID сценария есть в его имени (scenario_ids), а к требованию — через
сценарии этого требования. Возвращает словарь:
"matrix": {"REQ-001": {"scenarios": [...], "tests": [...]}, ...} — по порядку требований;
сценарии — те, что ссылаются на требование (порядок сценариев в спецификации);
тесты — тесты этих сценариев по порядку test_names, без повторов;
"uncovered": требования без единого теста;
"untested_scenarios": сценарии спецификации без единого теста;
"orphan_tests": «сиротские» тесты — ссылаются хотя бы на один сценарий, которого в спецификации нет;
"untraced_tests": тесты без ID сценария (модульные — это нормально, но их видно);
"coverage": доля требований хотя бы с одним тестом, в процентах: round(100 * покрыто / всего, 1);
если требований нет — 0.0.
Все списки — в порядке появления (требований, сценариев или тестов).
"""
# TODO: ваш код
...
def format_report(spec_text, test_names):
"""Отчёт для человека (готовая функция)."""
report = trace(spec_text, test_names) or {}
lines = [f"Покрытие требований тестами: {report.get('coverage', 0.0)}%"]
for req, row in (report.get("matrix") or {}).items():
tests = ", ".join(t.split("::")[-1] for t in row["tests"]) or "—"
lines.append(f" {req} {', '.join(row['scenarios']) or 'нет сценариев':<16} {tests}")
for key, title in [("uncovered", "Требования без тестов"), ("untested_scenarios", "Сценарии без тестов"),
("orphan_tests", "Сиротские тесты"), ("untraced_tests", "Тесты без ID сценария")]:
items = report.get(key) or []
lines.append(f"{title}: {', '.join(i.split('::')[-1] for i in items) or 'нет'}")
return "\n".join(lines)
print(format_report(EXAMPLE_SPEC, EXAMPLE_TESTS))
Готовую функцию trace можно подключить к CI: прочитать spec.md, получить имена тестов командой
python -m pytest --collect-only -q и упасть, если появились сиротские тесты или покрытие стало ниже прошлого.
Так дрейф заметят в день его появления, а не через месяц.
Проверь себя
Заголовок раздела «Проверь себя»- Приёмочный тест — сценарий в исполняемой форме: данные буква в букву, одно действие, assert на наблюдаемый результат, ID сценария в имени. Отчёт pytest тогда читается как список правил.
- Трассируемость держится на ID: строка «Требования:» у сценария, имя теста, поля задачи, сообщение коммита. Матрица показывает непокрытые требования, сценарии без тестов и сиротские тесты и считает покрытие.
- С агентом тесты пишут первым шагом и ревьюят до кода: проверить шесть строк теста дешевле, чем шестьдесят строк логики. На шаге кода тесты заморожены.
- Мутационное тестирование проверяет сами тесты. Выжившие мутанты чаще всего указывают на пробел в спецификации: нет сценария или «Тогда» неполное. Сначала правят спецификацию.
- Ревью по чек-листу ловит то, чего в спецификации нет: лишний код, ослабленные тесты, опасные вызовы, новые зависимости. Дрейф ловят тесты, матрица, коммиты без ID и converge. Сводят расхождение по правилу «сначала спецификация».
Дальше: урок SDD.4.3 — подводные камни и критика SDD. Многословные спецификации, ложное чувство контроля, недетерминизм модели, цена ревью markdown — и когда лучше обойтись маленькими итерациями без тяжёлого процесса.