Property-based и snapshot: тысяча проверяющих и ловушка approve
Сцена · тысяча проверяющих вместо одного
До сих пор ты писал примеры руками: вот вход, вот ожидаемый выход. Это и есть тест как ТЗ. Но три примера это три точки, а между ними целое поле входов, которое ты не проверил. Именно там обычно и сидит баг на границе.
Property-based тест переворачивает подход. Вместо “вот три примера” ты пишешь свойство, которое должно держаться всегда, а инструмент сам кидает в него тысячи случайных входов и ищет тот, на котором свойство ломается. Один проверяющий превращается в тысячу. А когда контрпример найден, инструмент ещё и сжимает его до самого маленького, чтобы ты не разбирался в случайном восьмизначном числе.
Карта урока · что заберёшь
- поймёшь, что такое свойство и чем оно сильнее набора примеров;
- увидишь, как fast-check находит контрпример и сжимает его (shrinking);
- научишься распознавать кандидатов в property: round-trip и инварианты;
- разберёшь, когда snapshot полезен, а когда “approve all” делает его фикцией.
Раздел 1 · свойство вместо примеров
Идея словами
Property-based тест это утверждение, истинное для любого допустимого входа. Не “parseAmount('12.34') равно 1234”, а “для любой суммы parseAmount(formatAmount(m)) равно m”. Инструмент (у нас это fast-check) генерирует входы из arbitrary и гоняет свойство на каждом.
Два класса свойств ловят почти всё:
- round-trip:
decode(encode(x))равноx. Парсинг и форматирование, сериализация и разбор, кодирование и декодирование. Если они не обратны, round-trip это вскроет. - инвариант: свойство, истинное после любой операции. Прямо тот инвариант баланса из урока 01: “поле balance равно пересчёту из истории”.
Картинка · поиск и сжатие
Нажми “запустить fast-check”. Сначала инструмент кидает огромные случайные входы, часть проходит. Потом натыкается на падающий и переключается в режим сжатия: огромное число шаг за шагом ужимается до минимального m = 1. Ручной пример такого почти никогда не находит, а fast-check находит и сразу упрощает до понятного.
Шаг 1 · round-trip как property
В Kassa пара parseAmount/formatAmount задумана обратной. Вместо трёх примеров пишем одно свойство:
// packages/ledger/test/properties.test.ts
import fc from 'fast-check';
import { describe, expect, it } from 'vitest';
describe('parseAmount и formatAmount round-trip', () => {
it('parseAmount(formatAmount(m)) равно m для любой суммы', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 1_000_000_000 }), (raw) => {
const value = money(raw);
expect(parseAmount(formatAmount(value))).toBe(value);
}),
);
});
});
fc.property берёт arbitrary (неотрицательные целые копейки) и свойство. fc.assert гоняет это на сотнях входов. Один тест покрывает то, на что руками ушли бы десятки примеров, и всё равно не попал бы в граничную копейку.
Что взять с собой
- свойство истинно для любого входа, пример только для одного;
- два рабочих класса: round-trip и инвариант;
fc.assert(fc.property(arb, prop))гоняет свойство на множестве входов.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.