Раздел 46 · Effect: Тихая Академия

Кто это проверил: тесты на Effect

middle-senior~25 мин

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

* Профессором Тихой Академии становятся не по выслуге. Кандидат приносит не диплом, а работающую вещь, и экзаменаторы весь день пытаются её сломать. Если к вечеру вещь работает, кандидату дают мантию. Если нет, ему дают молоток и показывают, где он ошибся.

Писать тесты на Effect ты уже умеешь. В уроке 53 появились it.effect, дублёры на Layer.mock и общий слой на блок, в уроке 74 часы TestClock, которые идут, только когда их двигает тест. Этот урок про вопрос Рыдкулла: чего стоит тест. Ровно той ошибки, которую он поймает. Зелёный набор, который не замечает сломанный код, хуже никакого: он даёт спокойствие, которого нет.

Обещания сторожа

Сторожа башни написали давно, и код короткий:

export const watchTower = (code: string): Effect.Effect<Verdict, never, TowerBook> =>
  Effect.flatMap(TowerBook, (book) => book.ping(code)).pipe(
    Effect.retry({ schedule: Schedule.spaced('10 seconds'), times: 3 }),
    Effect.as<Verdict>('open'),
    Effect.orElseSucceed((): Verdict => 'closed'),
  );

Тест проверяет не строки, а обещания. Выпиши их словами, прежде чем писать хоть один expect:

  • башня ответила на первый пинг: 'open', и пинг был один;
  • башня молчит: ещё три попытки, всего четыре пинга;
  • между пингами ровно десять секунд, не секунда и не минута;
  • ответила на любой из четырёх: 'open', молчит на всех: 'closed', наружу ни одной ошибки.

Каждое обещание это будущий тест, и у каждого свой способ сломаться: times: 2, повтор через секунду, перепутанные вердикты. Хороший набор ловит каждый такой случай хотя бы одним красным тестом.

it.effect и it.live

У @effect/vitest два главных способа зарегистрировать тест на Effect.

import { describe, expect, it } from '@effect/vitest';

it.effect('башня отвечает сразу', () =>
  Effect.gen(function* () {
    const pings = yield* Ref.make(0);
    const verdict = yield* Effect.provide(watchTower('T-1'), bookLayer(pings, 1));
    expect(verdict).toBe('open');
    expect(yield* Ref.get(pings)).toBe(1);
  }),
);

it.effect запускает эффект за тебя и кладёт в контекст тестовые сервисы: часы TestClock и консоль TestConsole, которая не печатает, а копит строки (TestConsole.logLines). После теста закрывается его Scope, и всё, что тест открыл через acquireRelease, закрывается тоже.

it.live такой же, но на настоящих сервисах: часы тикают сами, консоль печатает. Он нужен, когда время должно идти по-настоящему, как у кластера из урока 108. Для своего кода выбор почти всегда it.effect: десять секунд ожидания в нём проходят мгновенно.

Время, которое двигает тест

Второе обещание: между пингами десять секунд. Под it.effect сторож, который ждёт, сам не проснётся. Его запускают в фоне и двигают часы:

const watchFor = (answersOn: number, seconds: number) =>
  Effect.gen(function* () {
    const pings = yield* Ref.make(0);
    const fiber = yield* Effect.forkChild(
      Effect.provide(watchTower('T-1'), bookLayer(pings, answersOn)),
    );
    yield* TestClock.adjust(`${seconds} seconds`);
    return { pings, fiber };
  });

it.effect('между пингами десять секунд', () =>
  Effect.gen(function* () {
    const { pings } = yield* watchFor(Infinity, 9);
    expect(yield* Ref.get(pings)).toBe(1);
    yield* TestClock.adjust('1 second');
    expect(yield* Ref.get(pings)).toBe(2);
  }),
);

Через девять секунд пинг один, через десять два. Такой тест ловит и повтор через секунду (на девятой секунде пингов было бы уже четыре), и повтор через минуту (на десятой секунде всё ещё один). Тест «молчащая башня закрыта» делает один прыжок adjust('30 seconds') и забирает вердикт Fiber.join(fiber): все три повтора успевают случиться внутри одного прыжка.

Под капотом

Список снов TestClock из урока 74 не статичен. Сначала adjust даёт фоновым файберам ход (запускает дочерний файбер с одним yieldNow и ждёт его), и сторож успевает пингнуть и уснуть на десять секунд. Потом adjust берёт самый ранний сон, переводит часы на его момент и будит файбер. Сторож пингует, получает отказ, Schedule.spaced кладёт новый сон на двадцатую секунду, и только когда сторож снова уснул, adjust смотрит в список дальше. Новый сон раньше конца прыжка, и всё повторяется. Поэтому один прыжок проходит всю цепочку повторов, а файбер на каждом шаге видит на часах свой момент.

Дублёр со счётчиком

Книга башен в тесте это дублёр. Ему нужно не только отвечать, но и запоминать, сколько раз его спросили, иначе обещание «всего четыре пинга» не проверить:

const bookLayer = (pings: Ref.Ref<number>, answersOn: number) =>
  Layer.succeed(TowerBook)({
    ping: () =>
      Ref.updateAndGet(pings, (n) => n + 1).pipe(
        Effect.flatMap((n) => (n >= answersOn ? Effect.void : Effect.fail('молчит'))),
      ),
  });

Башня отвечает начиная с пинга номер answersOn: 1 значит сразу, 4 значит на последнюю попытку, Infinity значит никогда. Один дублёр покрывает все случаи сторожа.

Почему Ref передают в слой, а не создают внутри? Общий слой layer(...) строится один раз на блок, как в уроке 53, и тесты блока делили бы один счётчик: второй начал бы с того места, где остановился первый. У настоящего @effect/vitest есть it.layer(слой, { buildPerTest: true }), но проще правило: состояние, которое тест проверяет, тест и создаёт.

Свойства вместо примеров

Вторая функция сторожа режет письма на пачки:

export const batches = <A>(letters: ReadonlyArray<A>, size: number): Array<Array<A>> => { ... };

Пример batches([1, 2, 3], 2) это [[1, 2], [3]]. А пустой список? Размер больше длины? Примеров можно написать двадцать и не угадать двадцать первый. Вместо примеров пишут свойства: что верно для любых писем и любого размера.

  • Пачки, склеенные обратно, дают те же письма: ничего не потерялось, ничего не переставилось.
  • Ни одна пачка не пуста и не длиннее size.
const Letters = Schema.Array(Schema.Int);
const Size = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 }));

it.effect.prop(
  'пачки, склеенные обратно, дают те же письма',
  { letters: Letters, size: Size },
  ({ letters, size }) => Effect.sync(() => expect(batches(letters, size).flat()).toEqual(letters)),
);

it.effect.prop берёт схемы, выводит из них генераторы, как в уроке 83, и прогоняет свойство на множестве случайных входов. Свойство провалилось, если вернуло false, упало expect или закончилось ошибкой. Тогда fast-check ужимает вход до самого маленького, на котором свойство ещё падает, и тест показывает его: не «упало на списке из сорока чисел», а «упало на [0] с размером 2». Для синхронного свойства есть it.prop, тело там обычная функция.

Ограничение Size важно: при size: 0 функция зациклится, а это не баг сторожа, а вход вне договора. Генератор должен давать только то, что функция обещала принимать.

Где тут дыра

it('batches режет через slice', () => {
  const slice = vi.spyOn(Array.prototype, 'slice');
  batches([1, 2, 3], 2);
  expect(slice).toHaveBeenCalledTimes(2);
});

it.live('молчащая башня закрыта', () =>
  Effect.gen(function* () {
    const verdict = yield* Effect.provide(watchTower('T-1'), bookLayer(yield* Ref.make(0), Infinity));
    expect(verdict).toBe('closed');
  }),
);

Первый тест проверяет, как функция устроена, а не что она делает. Перепиши batches через Array.chunksOf: поведение то же, а тест красный, и скоро команда перестанет верить красным тестам. А настоящий баг он пропускает: сделай пачку на одно письмо шире, и slice позовётся те же два раза, а пачки выйдут [[1, 2, 3], [3]].

Второй тест идёт тридцать секунд на настоящих часах и проверяет меньше, чем тест на TestClock: «когда-нибудь закрыта», а не «четыре пинга через десять секунд». Сто таких тестов, и набор идёт час.

Лечится одним правилом: тест смотрит на то, что видно снаружи. Возвращённое значение, ответ дублёра, счётчик пингов, время на тестовых часах.

Кто проверит тесты

Рыдкулл сказал, что сломал половину кода соседей, а их тесты остались зелёными. Так проверяют сами тесты: портят код и смотрят, заметил ли набор. Испорченная версия кода называется мутант. Мутант пойман, если хоть один тест покраснел; выжил, если набор остался зелёным. Выживший мутант показывает обещание, которое никто не проверяет.

Покрытие строк тут не поможет. Тест «башня отвечает сразу» проходит по каждой строке watchTower, покрытие сто процентов, а times: 2 вместо times: 3 он не заметит: до повторов дело не доходит.

Варианты кода в такой проверке бывают двух видов. Мутант с багом тесты обязаны поймать. А рефакторинг, версия без бага, но с другим устройством, обязан пройти зелёным. Вторая проверка ловит тесты Ринцвальда: тест, который краснеет на честном рефакторинге, смотрит на устройство.

Разминка проверяется именно так: сначала твои тесты идут на правильном watch.ts и должны быть зелёными, потом на пяти мутантах (две попытки вместо трёх, повтор через секунду, молчащая башня открыта, пропавший хвост пачек, пачка на одно письмо шире) и на рефакторинге batches через Array.chunksOf, где набор обязан остаться зелёным.

Разминка

Код в src/watch.ts трогать нельзя, твой файл src/watch.test.ts: дублёр bookLayer и первый тест уже там. Допиши тесты на повторы, время между ними, вердикт молчащей башни и два свойства на batches.

Практика

Глубже в архиве

Мутационное тестирование со стороны инструментов, mutmut для Python и Stryker для TypeScript, с отчётами и порогами в CI, разобрано в уроке курса QA.

Дальше

К вечеру тесты сторожа ловят всех пятерых мутантов Рыдкулла и не краснеют на рефакторинге. Рыдкулл молча снимает с манекена пылинку и уходит. Пондер листает журнал линии и хмурится.

Невозможные записи в журнале разберём в уроке 117.