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