Исполняемые спецификации на flang Примеры внутри правила: новый вид предметных unit-тестов
0%

Примеры внутри правила: новый вид предметных unit-тестов

Примеры внутри правила: новый вид предметных unit-тестов

Пример во flang — не текст в документации и не тестовый файл на другом языке. Это часть объявления функции, и компилятор вычисляет его при каждой проверке. В этой главе — чем это отличается от обычного unit-теста и почему сервис в этом курсе не поднимается, если пример разошёлся с правилом.

Минимальный пример

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

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

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

Это правило «Большая покупка» из модели static/spec/models/order-discount.flang, той же, что считает HTTP-сервис из следующей главы. Два примера стоят ровно на границе порога: 10 000 и 9 999. Во всей модели примеров десять, и каждый — исполняемое утверждение о правиле, а не иллюстрация.

Что делает компилятор

пример — это имя, входы дано и одно ожидается. Компилятор:

  1. проверяет типы примера: поля записи существуют, значения подходят по типу;
  2. вычисляет функцию на входах дано;
  3. сравнивает результат с ожидается;
  4. при несовпадении называет пример и оба значения.

Делают это обе команды. flang check гоняет примеры вместе с разбором и типами и падает кодом 1 на первом несовпадении. flang test гоняет только примеры и печатает счёт:

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

Код возврата 0 — все сошлись, 1 — что-то не сошлось. Этого достаточно для CI без обвязки. flang test static/spec/models/ берёт целый каталог, --json печатает итог одной строкой для машины.

Пример типизирован так же строго, как тело. Строка на месте признака или пропущенное поле — ошибка проверки, а не упавший тест:

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

Чем это отличается от unit-теста

Обычный unit-тест живёт в отдельном файле, на языке тестового фреймворка, и говорит терминами реализации: имя функции, структура аргументов, мок зависимости. Пример flang живёт сразу под правилом и говорит терминами предметной области: «постоянный клиент», «сумма». Аналитик может прочитать и предложить пример, не открывая код сервиса.

Имя примера — целое предложение, и оно попадает в каждое сообщение об ошибке. Поэтому его пишут как утверждение: «на девять тысяч девятьсот девяносто девять ещё нет», а не «тест 2».

Пример — ещё и опора для обещаний. Обещание обеспечивает без доказательства и без примеров отчёт называет «объявлено, не доказано»; с примерами — «сетка N»: проверено на N примерах, это не доказательство. Без примера доказанное обещание к тому же остаётся проверкой в напечатанном коде.

Стартовая проверка вместо позднего провала

examples/spec/discount-api/server.mjs прогоняет примеры модели один раз при старте, до открытия сокета:

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) };
}

Если в pull request поменяли правило и не заметили, что старый пример теперь считает иначе, сервис не запустится — на первой секунде, а не через час работы. server.test.mjs проверяет это обоими путями: поднимает настоящий сервер и читает /health, а модель с испорченным примером подаёт в createCalculator и ждёт отказа «примеры модели не прошли».

Покрытие правил примерами

Компилятор не требует, чтобы у функции были примеры. Функция без единого примера проходит и flang check, и flang test:

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

Это нужно знать заранее: «0 из 0» — зелёный ответ. Обязательность примеров держит ревью: новое правило без нового примера отклоняют по той же причине, по которой отклоняют код без теста.

Разумный набор для правила с порогом — по примеру на каждую сторону границы и на саму границу:

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

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

Три примера на одно правило — не избыточность, а проверка того места, где ошибаются чаще всего: не меньше включает границу. Замените не меньше на больше — и проверка назовёт сломанный пример:

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

Что всё равно тестировать обычным фреймворком

Примеры закрывают одну область — детерминированное предметное правило. Они не заменяют:

  • интеграционные тесты базы данных и очередей;
  • контрактные тесты HTTP-входа и выхода сервиса;
  • тесты компонентов React;
  • сквозные тесты пользовательского пути;
  • нагрузочные тесты и тесты безопасности.

server.test.mjs показывает границу на практике. Тест про /health и примеры модели — это работа flang. Тест про ответ 400 на неверный тип входа — уже HTTP-контракт, обычный node:test без обращения к компилятору.

Вместо сгенерированных тестов

Отдельного файла тестов на node:test flang не печатает. Примеры остаются в модели, а в CI работают два шага:

  1. flang test — примеры сходятся;
  2. flang emit --target ts и сверка с закоммиченным кодом.

Так устроен examples/spec/typescript-codegen/generate.mjs:

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() };
}

Порядок здесь важен: flang emit сам примеры не гоняет и печатает модель даже с несошедшимся примером. Режим --check падает, если кто-то поправил .ts руками мимо модели. Напечатанный код — артефакт сборки, а не второй источник правды. Если нужен тест на стороне приложения, его пишут обычным node:test поверх напечатанной функции — как generate.test.mjs, который подключает напечатанный .ts и сверяет два результата.

Практика в песочнице

  1. Найдите десять примеров модели и определите, какой проверяет одно правило, а какой — их сочетание.
  2. Допишите пример, где сумма 5000, клиент не постоянный, а ожидание намеренно неверное: 100 вместо 0. Проверьте и прочитайте, как названо несовпадение.
  3. Удалите оба примера у функции «Большая покупка» и найдите в отчёте о доказательствах, как изменилось обещание «скидка за объём не отрицательна».

Типичные ошибки

  • «0 из 0» принято за успех. Функция без примеров проходит проверку. Следите за примерами на ревью, а не ждите отказа от компилятора.
  • FLANG_EXAMPLE … значение не совпало с ожидаемым — пример не сошёлся. Читайте оба значения: часто ошибка не в правиле, а в самом примере, который считали в уме.
  • FLANG_TYPE … пример … — пример не подходит к типам входа: пропущено поле или значение другого типа. Это ошибка проверки, а не упавший тест.
  • flang emit без flang test. Печать не гоняет примеры. Ставьте flang test в CI до печати.

Чек-лист

  • У каждого правила есть примеры, и на пороге — по примеру с каждой стороны.
  • Новое правило приходит вместе с новым примером в том же pull request.
  • flang test стоит в CI и на старте сервиса, а не запускается только руками перед релизом.
  • Напечатанный код не правится вручную — правка идёт в модель, затем повторная печать.
  • Имя примера — предложение, понятное человеку, который не открывал код.

Кейсы каталога по этой теме

Дальше: Node.js

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

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

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

Доска запросов
Дальше