Раздел 29 · Тестирование

Property-based и snapshot: тысяча проверяющих и ловушка approve

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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.