Перейти к содержимому
Программа SDDSDD.4 Практика и качество

Проверка: тесты из спецификации и ревью

Как убедиться, что агент сделал то, что написано в спецификации: приёмочные тесты pytest из сценариев «Дано / Когда / Тогда», матрица трассируемости «требование → сценарий → тест», тесты сначала при работе с агентом, мутационное тестирование с mutmut, ревью кода агента по чек-листу, дрейф спецификации и кода и метрики процесса.

УРОКsdd/sdd-4-practice/02-verification.mdx2 ч 30 минУрок 2 из 4
После урока вы сможете
  • Превращать сценарии «Дано / Когда / Тогда» в приёмочные тесты pytest с ID сценария в имени и запускать их
  • Строить матрицу трассируемости и находить требования без тестов и сиротские тесты
  • Проверять сами тесты мутациями — вручную и инструментом 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.fixture
def 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 -v
tests/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
Одно требование и его следы: сценарии в спецификации, тесты с ID в имени, задача, коммит и код

Ссылки живут в пяти местах: строка «Требования:» у сценария, имя теста, поля задачи в 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)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 pathlib
import shutil
import subprocess
import sys
import 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 — после вопроса агента на уточнениях. Вопрос, превращённый в сценарий, стал тестом, который ловит реальную ошибку.

Вручную мутантов придумываете вы, и придумываете те, о которых уже подумали. Инструмент перебирает их систематически. Для Python есть mutmut (github.com/boxed/mutmut, лицензия BSD). Ему нужен системный вызов fork, поэтому на Windows он работает только внутри WSL. Мутантов он гоняет в копии проекта в папке mutants/, а команда mutmut apply записывает выбранного мутанта прямо в ваш файл — перед ней всё должно быть закоммичено, README предупреждает об этом отдельно. Установка и настройка — в виртуальном окружении проекта:

Окно терминала
pip install mutmut
printf '[mutmut]\nsource_paths=enrollment/\npytest_add_cli_args_test_selection=tests/\n' > setup.cfg
mutmut 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 часов остаётся». Выжившие разбиваются на четыре группы, и у каждой своё решение:

ГруппаСколькоПример мутантаЧто это значитРешение
логика условия1and → or в expire_unpaidнет сценария на важный случайновый сценарий SC-012 в спецификацию, потом тест
содержимое события11refund или момент в EnrollmentCancelled заменены на NoneREQ-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.2Booking, 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_… остался.

Как заметить дрейф:

  1. Приёмочные тесты краснеют, если код ушёл от сценария. Но только если сами тесты следуют спецификации. Поэтому правка «7 на 6» вместе с правкой теста — самый опасный вид дрейфа.

  2. Матрица трассируемости показывает сиротские тесты и требования без тестов.

  3. Коммиты без ID задач. У нас каждый коммит начинается с T-NNN, так что выпадающие видны одной командой:

    $ git log --format='%h %s' | grep -v -E 'T-[0-9]{3}'
    8dec2a5 срочно: возврат за 6 дней по просьбе поддержки
  4. Сверка инструментом. В 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 сценария и посчитать покрытие. В заготовке пример: спецификация записи на поток и пять тестов, среди которых один сиротский.

Матрица трассируемости: требования, сценарии и тесты7 тестов
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 и упасть, если появились сиротские тесты или покрытие стало ниже прошлого. Так дрейф заметят в день его появления, а не через месяц.

Проверь себя6 вопросов
1 Сценарий SC-006: «Дано у Анны подтверждённая запись; Когда Анна отменяет запись 26 октября в 10:00; Тогда Анне возвращается 3 000 000 копеек». Какой тест ему лучше соответствует?
2 В спецификации 12 требований. У 9 из них есть хотя бы один тест через сценарии. Какое покрытие требований тестами в процентах?
3 mutmut сообщает: мутант с заменой self.starts_at - now >= FULL_REFUND_PERIOD на > выжил. Что это значит?
4 Матрица трассируемости показала сиротский тест test_sc_013_offer_seat_from_waitlist. Что это означает и что делать?
5 Что в диффе агента — повод остановиться на ревью? Выберите все верные варианты.Можно выбрать несколько ответов
6 Поддержка попросила вернуть деньги студенту, который отменил запись за 6 суток. Разработчик поменял в коде 7 на 6 и поправил тест SC-006. Тесты зелёные. Что не так?
  • Приёмочный тест — сценарий в исполняемой форме: данные буква в букву, одно действие, assert на наблюдаемый результат, ID сценария в имени. Отчёт pytest тогда читается как список правил.
  • Трассируемость держится на ID: строка «Требования:» у сценария, имя теста, поля задачи, сообщение коммита. Матрица показывает непокрытые требования, сценарии без тестов и сиротские тесты и считает покрытие.
  • С агентом тесты пишут первым шагом и ревьюят до кода: проверить шесть строк теста дешевле, чем шестьдесят строк логики. На шаге кода тесты заморожены.
  • Мутационное тестирование проверяет сами тесты. Выжившие мутанты чаще всего указывают на пробел в спецификации: нет сценария или «Тогда» неполное. Сначала правят спецификацию.
  • Ревью по чек-листу ловит то, чего в спецификации нет: лишний код, ослабленные тесты, опасные вызовы, новые зависимости. Дрейф ловят тесты, матрица, коммиты без ID и converge. Сводят расхождение по правилу «сначала спецификация».

Дальше: урок SDD.4.3 — подводные камни и критика SDD. Многословные спецификации, ложное чувство контроля, недетерминизм модели, цена ревью markdown — и когда лучше обойтись маленькими итерациями без тяжёлого процесса.