FTS examples: a new kind of subject unit tests
An example in FTS is not text in documentation and not a separate test file in another language. It is a part of the model executed by the same engine that runs the rule, next to which it is written. In this chapter — how this differs from a regular unit test and why the service in this course does not start if the example diverges from the rule.
Minimal example
категория «Продажи»
объект Покупка
сумма является деньгами
«постоянный клиент» является признаком
утилита «Рассчитать скидку»
принимает Покупка
возвращает деньги
начинает с 0
правило «Большая покупка»
если сумма не меньше 10000
и сумма не больше 100000
то добавить 10 процентов от поля сумма
правило «Постоянный клиент»
если «постоянный клиент» равен да
и сумма больше 0
и сумма не больше 100000
то добавить 5 процентов от поля сумма
правило «Очень крупная покупка»
если сумма больше 100000
то добавить 15000
свойство «Скидка ограничена»
результат не больше 15000
пример «Обычная покупка»
дано сумма равна 5000
дано «постоянный клиент» равен нет
ожидается результат равен 0
пример «Постоянный клиент на пять тысяч»
дано сумма равна 5000
дано «постоянный клиент» равен да
ожидается результат равен 250
пример «Большая покупка постоянного клиента»
дано сумма равна 20000
дано «постоянный клиент» равен да
ожидается результат равен 3000
пример «Покупка на потолок скидки»
дано сумма равна 100000
дано «постоянный клиент» равен да
ожидается результат равен 15000
пример «Очень крупная покупка»
дано сумма равна 200000
дано «постоянный клиент» равен нет
ожидается результат равен 15000
This is the same file used by the HTTP service in the next chapter:
static/fts/models/order-discount.fts.
Five examples — not an illustration in the article, but five executable statements about
the rules above.
What the compiler does
пример walks through the structure with input дано and one ожидается.
A separate fts test team does not read documentation and does not parse comments —
it finds in the model all examples of all utilities and for each:
- substitutes values
даноinto the utility’s input; - performs
evaluateUtility— the same six steps as a regular call (начинает с, rules in order, properties); - compares the actual result with
ожидается результат равен ...viaObject.is, that is, strictly, without implicit type coercion; - if the utility never terminated with an exception, but the result did not match — the example is considered failed; if a rule or property itself threw an error (for example, a property was violated) — the example is also considered failed, and the reason is included in the report.
fts test static/fts/models/order-discount.fts --pretty
The command returns JSON with the number of passed and failed examples and exits with a non-zero code if at least one did not match — this is enough to plug it into CI without additional wrapping.
What is the difference from a unit test
A regular unit test lives in a separate file, in the language of the test framework, and
uses implementation terms: function name, argument structure, mock
dependency. An example FTS lives inside a .fts file, right under the rule that
checks it, and uses domain terms: “regular customer”, “amount”, not input.amount and input.isLoyal. An analyst or domain
expert can read and suggest an example without opening the source code.
At the same time, an example is not a textual illustration like a docstring with expected output. It is typed (the compiler checks that fields in дано exist in the object принимает), executed by the interpreter during fts test, and re-executed in the generated node:test file after fts generate. One entry — three places to check the same contract.
Early Check Instead of Late Failure
examples/fts/discount-api/server.mjs calls testUtilities once on
startup, before opening the socket:
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('; '),
);
}
If someone in a pull request changed a rule and didn’t notice that the old example
now calculates differently, the service won’t start — not after an hour of operation, but on
the first second. server.test.mjs checks the same path via HTTP: it runs
a real server on a random port and accesses it through fetch, without
mocking. Test сервис не поднимается без прохождения предметных примеров
reads /health and checks that examples equals 5/5 — that is, that all five
model examples completed before the server responded to even one request.
Coverage of rules by examples
FTS does not require automatic 100% coverage, but the file structure pushes toward it: for a utility with three rules and one property, it makes sense to see examples for at least these cases — no rule triggered, each rule triggered individually, several rules triggered together, and (separately, not in the article model, but in the exercise below) a case breaking the property. A reviewer who sees a new rule without a new example is justified in rejecting the change for the same reason changes without tests are rejected.
правило «Порог»
если сумма не меньше 10000
то добавить 10 процентов от поля сумма
пример «Ровно ниже порога»
дано сумма равна 9999
ожидается результат равен 0
пример «Ровно на пороге»
дано сумма равна 10000
ожидается результат равен 1000
пример «Выше порога»
дано сумма равна 10001
ожидается результат равен 1000.1
Three examples for one rule — not redundancy, but checking exactly the spot
where people most often make mistakes: не меньше includes the boundary, rather than excluding it.
What to test with a regular framework anyway
FTS examples cover one specific area — deterministic subject policy. They do not replace:
- database and queue integration tests;
- HTTP contract tests for service input and output;
- component tests for React behavior;
- E2E user journey tests;
- load and security tests.
server.test.mjs shows the boundary in practice: a test about /health and the number of examples is still an FTS competency; a test about the 400 status when the input type is incorrect is already an HTTP contract, a regular node:test without referring to testUtilities.
Generation node:test
fts generate static/fts/models/order-discount.fts --out generated
fts.utilities.ts and fts.utilities.test.ts will appear — regular TypeScript and tests on the built-in node:test, built from the same examples. In the course this is shown in examples/fts/typescript-codegen: the --check team compares the written code with what the model would generate now, and fails if someone changed .ts by hand outside of .fts. The generated file is a build artifact, not a second source of truth.
Practice in the sandbox
- On the “Examples” tab, find all five model examples and determine which rule checks each of them individually, and which one checks their combination.
- Add an example where the sum equals 5000, and the regular customer is
нет, but the expected result is intentionally incorrect (e.g., 100 instead of 0). Switch to tabrunand make sure the mismatch is immediately visible, not masked. - Remove all examples from the utility and see what diagnostics the tab
returns — it is the same code that the
fts testcommand would return in the terminal.
Common Fallacies
FTS_NO_UTILITY_EXAMPLES— the model has a utility, but noпример.fts testandtestUtilitiesdo not consider this an empty success: without examples there is nothing to check, and the team ends with an error, not a report “0 out of 0”. A rule without examples is an unfinished utility, not a ready one.- A mismatch between
ожидаетсяand the actual result does not throw a special code: the example is simply markedpassed: falsein the reportfts test, with fieldsexpectedandactualnearby. Read both values — often the error is not in the rule, but in the example itself, which was calculated in mind and once made a mistake in percentage. - A similar in spirit, but not identical code —
FTS_WITNESS_MISMATCH. It relates not to utility examples, but to theorems and proofs: when real data in JSON context do not match what is declared inдано. This mechanism is the subject of a separate chapter of the course on morphisms and theorems.
Checklist
- Each utility has at least one example — otherwise
fts testwill refuse to run. - A new rule is accompanied by a new example in the same pull request.
fts test model.fts --prettyis integrated into CI and into the local service startup, not run manually only before a release.- Generated
fts.utilities.test.tsare not edited manually — changes are made in.fts, thenfts generateis repeated. - The example is read by someone who hasn’t opened the code: field names — the same, as in the object, without abbreviations and camelCase.