Примеры внутри правила: новый вид предметных unit-тестов
Код в этой главе записан в прежней поверхности языка — со словами
категория,объект,утилита. Сегодняшний компилятор её слова читает, но программой такой файл не считает: файл, где есть только утилиты,flang checkотклоняет. Разбор задачи в главе верен; синтаксис переносится по таблице из главы «Старые модели».
Пример в FTS — не текст в документации и не отдельный тестовый файл на другом языке. Это часть модели, которая выполняется тем же движком, что и правило, рядом с которым она написана. В этой главе — чем это отличается от обычного unit-теста и почему сервис в этом курсе не поднимается, если пример разошёлся с правилом.
Минимальный пример
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Большая покупка»
если сумма не меньше 10000
и сумма не больше 100000
то добавить 10 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 5 процентов от поля сумма
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 15000
свойство «Скидка ограничена»
результат не больше 15000
пример «Обычная покупка»
дано сумма равна 5000
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Постоянный клиент на пять тысяч»
дано сумма равна 5000
дано «постоянный клиент» равен да
ожидается результат равен 250
пример «Большая покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 3000
пример «Покупка на потолок скидки»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 15000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен нет
ожидается результат равен 15000
Это тот же файл, что использует HTTP-сервис из следующей главы:
static/spec/models/order-discount.fts.
Пять примеров — не иллюстрация в статье, а пять исполняемых утверждений о
правилах выше.
Что делает компилятор
пример разбирается в структуру с входными дано и одним ожидается.
Отдельная команда fts test не читает документацию и не парсит комментарии —
она находит в модели все примеры всех утилит и для каждого:
- подставляет значения
даново вход утилиты; - выполняет
evaluateUtility— те же шесть шагов, что и обычный вызов (начинает с, правила по порядку, свойства); - сравнивает фактический результат с
ожидается результат равен ...черезObject.is, то есть строго, без неявного приведения типов; - если утилита ни разу не завершилась исключением, но результат не совпал — пример считается упавшим; если правило или свойство сами бросили ошибку (например, нарушено свойство) — пример тоже считается упавшим, а причина попадает в отчёт.
fts test static/spec/models/order-discount.fts --pretty
Команда возвращает JSON с числом пройденных и упавших примеров и завершается ненулевым кодом, если хотя бы один не сошёлся, — этого достаточно, чтобы воткнуть её в CI без дополнительной обвязки.
Чем это отличается от unit-теста
Обычный unit-тест живёт в отдельном файле, на языке тестового фреймворка, и
использует термины реализации: имя функции, структуру аргументов, мок
зависимости. Пример FTS живёт внутри .fts-файла, сразу под правилом, которое
проверяет, и использует термины предметной области: «постоянный клиент»,
«сумма», а не input.amount и input.isLoyal. Аналитик или предметный
эксперт может прочитать и предложить пример, не открывая исходный код.
При этом пример — не текстовая иллюстрация вроде docstring с ожидаемым
выводом. Он типизирован (компилятор проверяет, что поля в дано существуют в
объекте принимает), исполняется интерпретатором при fts test и повторно
исполняется в сгенерированном node:test-файле после fts generate. Одна
запись — три места проверки одного и того же контракта.
Стартовая проверка вместо позднего провала
examples/spec/discount-api/server.mjs вызывает testUtilities один раз при
старте, до открытия сокета:
const document = assertValid(compile(await readFile(modelFile, 'utf8')));
const tests = testUtilities(document);
if (!tests.valid) {
const failed = tests.results.filter((result) => !result.passed);
throw new Error(
`FTS-примеры не прошли (${failed.length} из ${tests.total}): ` +
failed.map((result) => `«${result.example}» ожидалось ${result.expected}, получено ${result.actual}`).join('; '),
);
}
Если кто-то в pull request поменял правило и не заметил, что старый пример
теперь считает иначе, сервис не запустится — не через час эксплуатации, а на
первой секунде. server.test.mjs проверяет тот же путь через HTTP: поднимает
реальный сервер на случайном порту и обращается к нему через fetch, без
моков. Тест сервис не поднимается без прохождения предметных примеров
читает /health и сверяет, что examples равно 5/5 — то есть что все пять
примеров модели прошли до того, как сервер ответил хоть на один запрос.
Покрытие правил примерами
FTS не требует стопроцентного покрытия автоматически, но структура файла подталкивает к нему: у утилиты с тремя правилами и одним свойством разумно видеть примеры хотя бы на такие случаи — ни одно правило не сработало, каждое правило сработало по отдельности, несколько правил сработали вместе, и (отдельно, не в модели статьи, а в упражнении ниже) случай, ломающий свойство. Ревьюер, который видит новое правило без нового примера, вправе отклонить изменение по той же причине, по которой отклоняют код без теста.
правило «Порог»
если сумма не меньше 10000
то добавить 10 процентов от поля сумма
пример «Ровно ниже порога»
дано сумма равна 9999
ожидается результат равен 0
пример «Ровно на пороге»
дано сумма равна 10000
ожидается результат равен 1000
пример «Выше порога»
дано сумма равна 10001
ожидается результат равен 1000.1
Три примера на одно правило — не избыточность, а проверка именно того места,
где чаще всего ошибаются: не меньше включает границу, а не исключает её.
Что всё равно тестировать обычным framework
FTS examples закрывают одну конкретную область — детерминированную предметную политику. Они не заменяют:
- интеграционные тесты базы данных и очередей;
- HTTP contract tests входа и выхода сервиса;
- component tests поведения React;
- E2E пользовательского пути;
- нагрузочные и security tests.
server.test.mjs показывает границу на практике: тест про /health и число
примеров — это всё ещё FTS-компетенция; тест про статус 400 при неверном
типе входа — это уже HTTP-контракт, обычный node:test без обращения к
testUtilities.
Генерация node:test
fts generate static/spec/models/order-discount.fts --out generated
Появятся fts.utilities.ts и fts.utilities.test.ts — обычный TypeScript и
тесты на встроенном node:test, построенные из тех же примеров. В курсе это
показано в examples/spec/typescript-codegen: команда --check сравнивает
записанный код с тем, что сгенерировала бы модель сейчас, и падает, если
кто-то поправил .ts руками мимо .fts. Сгенерированный файл — build
artifact, а не второй источник истины.
Практика в песочнице
- На вкладке «Примеры» найдите все пять примеров модели и определите, какое правило проверяет каждый из них по отдельности, а какой — их сочетание.
- Допишите пример, где сумма равна 5000, а постоянный клиент —
нет, но ожидание намеренно указано неверно (например, 100 вместо 0). Переключитесь на вкладкуrunи убедитесь, что несовпадение видно сразу, а не маскируется. - Уберите у утилиты все примеры и посмотрите, какую диагностику вернёт
вкладка — это тот же код, что вернула бы команда
fts testв терминале.
Типичные ошибки
FTS_NO_UTILITY_EXAMPLES— в модели есть утилита, но ни одногопример.fts testиtestUtilitiesне считают это пустым успехом: без примеров нечего проверять, и команда завершается ошибкой, а не отчётом «0 из 0». Правило без примера — недоделанная утилита, а не готовая.- Несовпадение
ожидаетсяс фактическим результатом не бросает специальный код: пример просто помечаетсяpassed: falseв отчётеfts test, с полямиexpectedиactualрядом. Читайте оба значения — часто ошибка не в правиле, а в самом примере, который считали в уме и один раз ошиблись в проценте. - Похожий по духу, но не тождественный код —
FTS_WITNESS_MISMATCH. Он относится не к примерам утилит, а к теоремам и доказательствам: когда реальные данные в JSON-контексте не совпадают с тем, что заявлено вдано. Эта механика — тема отдельной главы курса про морфизмы и теоремы.
Чек-лист
- У каждой утилиты есть хотя бы один пример — иначе
fts testоткажется запускаться. - Новое правило сопровождается новым примером в том же pull request.
fts test model.fts --prettyвстроен в CI и в старт локального сервиса, а не запускается только руками перед релизом.- Сгенерированные
fts.utilities.test.tsне редактируются вручную — правки вносятся в.fts, затем повторяетсяfts generate. - Пример читается человеком, который не открывал код: имена полей — те же, что в объекте, без сокращений и camelCase.