Executable specifications in flang Examples inside the rule: a new kind of subject unit test
0%

Examples inside the rule: a new kind of subject unit test

Examples inside the rule: a new kind of subject unit test

An example in flang is not text in documentation and not a test file in another language. It is part of the function’s declaration, and the compiler evaluates it on every check. This chapter is about how that differs from an ordinary unit test and why the service in this course does not start if an example has drifted away from the rule.

Minimal example

модуль «Продажи»

объект «Покупка»
  «сумма»: неотрицательное
  «постоянный клиент»: признак

тотальная функция «Большая покупка»
  принимает покупка: «Покупка»
  возвращает число
  обеспечивает «скидка за объём не отрицательна» результат не меньше 0
  пример «на десять тысяч скидка за объём уже есть»
    дано покупка равно запись «Покупка» с «сумма» равным 10000 и «постоянный клиент» равным нет
    ожидается 1000
  пример «на девять тысяч девятьсот девяносто девять ещё нет»
    дано покупка равно запись «Покупка» с «сумма» равным 9999 и «постоянный клиент» равным нет
    ожидается 0
  если покупка.«сумма» не меньше 10000 и притом покупка.«сумма» не больше 100000
    то (покупка.«сумма» умножить на 10) делить на 100
    иначе 0

This is the “large purchase” rule from the model static/spec/models/order-discount.flang, the same one the HTTP service in the next chapter computes with. Both examples sit exactly at the threshold: 10,000 and 9,999. The whole model has ten examples, and each is an executable statement about a rule, not an illustration.

What the compiler does

A пример is a name, the дано inputs and one ожидается. The compiler:

  1. checks the example’s types: the record fields exist and the values fit their types;
  2. evaluates the function on the дано inputs;
  3. compares the result with ожидается;
  4. on a mismatch, names the example and both values.

Both commands do this. flang check runs the examples together with parsing and type checking and fails with code 1 on the first mismatch. flang test runs only the examples and prints the count:

flang test static/spec/models/order-discount.flang
static/spec/models/order-discount.flang: примеров 10, прошло 10, не прошло 0

Exit code 0 means everything matched, 1 means something did not. That is enough for CI without any wrapping. flang test static/spec/models/ takes a whole directory, and --json prints the summary as one line for a machine.

An example is typed as strictly as the body. A string in place of a flag or a missing field is a check error, not a failed test:

FLANG_TYPE … пример «обычная покупка»: аргумент «покупка».постоянный клиент не соответствует типу признак
FLANG_TYPE … пример «обычная покупка»: аргумент «покупка»: не задано поле «промокод» записи «Покупка»

How this differs from a unit test

An ordinary unit test lives in a separate file, in the language of a test framework, and speaks in terms of the implementation: a function name, the shape of the arguments, a mocked dependency. A flang example lives right under the rule and speaks in terms of the subject domain: a regular customer, an amount. An analyst can read and propose an example without opening the service code.

An example’s name is a whole sentence, and it appears in every error message. So it is written as a statement — “nine thousand nine hundred ninety nine does not yet qualify” — and not as “test 2”.

An example is also what a promise rests on. An обеспечивает promise with neither a proof nor examples is called “declared, not proved” in the report; with examples it is “сетка N”: checked on N examples, which is not a proof. Without an example, even a proved promise stays a check in the printed code.

A startup check instead of a late failure

examples/spec/discount-api/server.mjs runs the model’s examples once at startup, before opening the socket:

export async function createCalculator(modelFile = MODEL_FILE) {
  const compiler = await flang();
  const tests = await compiler.test(modelFile);
  if (tests.code !== 0) throw new Error(`примеры модели не прошли:\n${tests.output}`);
  const passed = /примеров (\d+), прошло (\d+)/u.exec(tests.output);
  const { functions } = await importModel(modelFile);
  return { examples: passed ? `${passed[2]}/${passed[1]}` : '?', calculate: functions.get(FUNCTION) };
}

If a pull request changed a rule and nobody noticed that an old example now computes differently, the service will not start — in the first second, not after an hour of work. server.test.mjs checks this both ways: it starts a real server and reads /health, and it feeds a model with a spoiled example to createCalculator and expects the refusal “the model’s examples did not pass”.

Covering rules with examples

The compiler does not require a function to have examples. A function without a single example passes both flang check and flang test:

noex.flang: примеров 0, прошло 0, не прошло 0

This is worth knowing in advance: “0 of 0” is a green answer. Code review is what makes examples mandatory: a new rule without a new example is rejected for the same reason code without a test is rejected.

A sensible set for a rule with a threshold is one example on each side of the boundary and one on the boundary itself:

модуль «Порог»

тотальная функция «Скидка за порог»
  принимает сумма: неотрицательное
  возвращает число
  пример «ровно ниже порога»
    дано сумма равно 9999
    ожидается 0
  пример «ровно на пороге»
    дано сумма равно 10000
    ожидается 1000
  пример «выше порога»
    дано сумма равно 10001
    ожидается 1000.1
  если сумма не меньше 10000
    то (сумма умножить на 10) делить на 100
    иначе 0

Three examples for one rule are not redundancy but a check of the place where mistakes are most common: не меньше includes the boundary. Replace не меньше with больше and the check names the broken example:

FLANG_EXAMPLE: пример «ровно на пороге» функции «Скидка за порог»: значение не совпало с ожидаемым: ожидалось 1000, получено 0

What to test with an ordinary framework anyway

Examples cover one area — a deterministic subject rule. They do not replace:

  • integration tests of the database and queues;
  • contract tests of the service’s HTTP input and output;
  • React component tests;
  • end-to-end tests of the user’s path;
  • load and security tests.

server.test.mjs shows the boundary in practice. The test about /health and the model’s examples is flang’s job. The test about a 400 answer to a wrong input type is already the HTTP contract, an ordinary node:test that does not touch the compiler.

Instead of generated tests

flang does not print a separate node:test test file. The examples stay in the model, and CI runs two steps:

  1. flang test — the examples match;
  2. flang emit --target ts and a comparison with the committed code.

That is how examples/spec/typescript-codegen/generate.mjs is built:

export async function generate(modelFile = DEFAULT_MODEL) {
  const compiler = await flang();
  const tests = await compiler.test(modelFile);
  if (tests.code !== 0) throw new Error(`модель не проходит свои примеры:\n${tests.output}`);
  const printed = await compiler.emit(modelFile, 'ts');
  const files = Object.entries(printed)
    .map(([path, content]) => ({ path, content }))
    .sort((a, b) => a.path.localeCompare(b.path));
  return { files, tests: tests.output.trim() };
}

The order matters here: flang emit does not run the examples itself and prints a model even when an example does not match. The --check mode fails if someone edited the .ts by hand, bypassing the model. The printed code is a build artifact, not a second source of truth. If you need a test on the application side, write it with ordinary node:test on top of the printed function — like generate.test.mjs, which imports the printed .ts and checks two results.

Practice in the sandbox

  1. Find the model’s ten examples and work out which one checks a single rule and which checks a combination.
  2. Add an example where the amount is 5000, the customer is not a regular, and the expectation is deliberately wrong: 100 instead of 0. Run the check and read how the mismatch is named.
  3. Delete both examples of the “large purchase” function and find in the proof report how the promise that the volume discount is not negative has changed.

Common mistakes

  • “0 of 0” taken for success. A function without examples passes the check. Watch for examples in review rather than waiting for the compiler to refuse.
  • FLANG_EXAMPLE … значение не совпало с ожидаемым — an example did not match. Read both values: often the mistake is not in the rule but in the example itself, worked out in someone’s head.
  • FLANG_TYPE … пример … — the example does not fit the input types: a field is missing or a value has another type. This is a check error, not a failed test.
  • flang emit without flang test. Printing does not run the examples. Put flang test in CI before printing.

Checklist

  • Every rule has examples, and at a threshold there is one on each side.
  • A new rule comes with a new example in the same pull request.
  • flang test runs in CI and at service startup, not only by hand before a release.
  • The printed code is not edited by hand — the change goes into the model, then the code is printed again.
  • An example’s name is a sentence understandable to someone who has not opened the code.

Catalog cases on this topic

Next: Node.js

Spotted a mistake? Select a fragment of the text — a bug icon will appear next to it.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Next