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

Тестовые слои и дублёры

middle-senior~20 мин

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

Две жалобы Рыдкулла об одном: слой это рецепт, и рецепт можно написать под задачу. Для теста нужен дублёр, который делает ровно то, что тест проверяет. Для прода нужна книга, которая не держит башню, пока грузится.

Дублёр, который не притворяется

Провод-дублёр у башни уже есть с урока 48: Wire.layerTest пишет строки в журнал. Тесту на ночь с обрывами нужен второй, который умеет рваться. Его собирают поверх настоящего провода, подставив вместо Магистрали функцию:

// внутри class Wire
static readonly layerFlaky = (broken: (line: string) => boolean) =>
  Wire.layer((line) =>
    broken(line) ? Effect.fail(new SendFailed({ line, reason: 'обрыв' })) : Effect.void,
  );

Дублёр использует настоящий Wire.layer, поэтому журнал ведётся так же, как в проде: оборванная строка в журнал не попадает. Тест проверяет не копию логики, а саму логику с подменённой линией.

Layer.mock

Теперь тест, которому книга не нужна вовсе: ночь из одних пингов. Книгу всё равно надо положить, nightShift её просит в R. Можно написать реализацию руками, но тогда непонятно, что тест про книгу думает. Layer.mock говорит это явно:

const noBook = Layer.mergeAll(Wire.layerTest, Layer.mock(Ledger)({})).pipe(
  Layer.provideMerge(Journal.layer),
);

Layer.mock(Ledger) принимает часть формы сервиса. Всё, чего ты не написал, становится заглушкой. Пока заглушку не зовут, всё работает:

{ acked: 0, ponged: 1, confirmed: 0, noted: 0 }

Если её позвать, она падает дефектом с понятным текстом:

UnimplementedError: bashnya/Ledger: Unimplemented method "wasSent"

Тест, который случайно дошёл до книги, скажет, какой сервис и какой метод он тронул. Тест сам документирует, на что он опирается.

Перевод с Ук

Частичная форма проверяется так же строго, как полная:

export const book = Layer.mock(Ledger)({ wasSent: (id) => id === 1 });
ex53c.ts(5,42): error TS2322: Type '(id: number) => id is 1' is not assignable to type '(id: number) => Effect<boolean, never, never>'.
  Type 'boolean' is not assignable to type 'Effect<boolean, never, never>'.

Снизу вверх: вернули boolean, а форма ждёт Effect<boolean>. Строкой выше компилятор пишет, какую функцию он увидел: (id: number) => id is 1. Это не опечатка: стрелка, которая сравнивает аргумент с литералом, TypeScript выводит как предикат типа. К делу это не относится, важен boolean в последней строке. Мок обязан соблюдать форму: дублёр, который возвращает не то, что настоящий сервис, проверит не тот код. Лечится Effect.succeed(id === 1).

it.effect и TestClock

Тесты на Effect пишут через @effect/vitest. Его главная функция it.effect: тест, тело которого квитанция. Её запускают за тебя, а в контекст кладут тестовые часы, TestClock.

import { describe, expect, it } from '@effect/vitest';
import { Effect, Fiber } from 'effect';
import { TestClock } from 'effect/testing';

it.effect('книга отвечает, когда её дочитали', () =>
  Effect.gen(function* () {
    const answer = yield* Effect.forkChild(Ledger.use((ledger) => ledger.wasSent(2)));
    yield* TestClock.adjust('999 millis');
    expect(answer.pollUnsafe()).toBeUndefined();
    yield* TestClock.adjust('1 millis');
    expect(yield* Fiber.join(answer)).toBe(true);
  }).pipe(Effect.provide(Ledger.layerLazy(Effect.as(Effect.sleep('1 second'), [1, 2])))),
);

Книга грузится секунду, а тест идёт миллисекунды. Effect.sleep внутри смотрит на часы из контекста, и в it.effect это тестовые часы: время стоит, пока тест не скажет TestClock.adjust. Через 999 миллисекунд ответа ещё нет, через тысячу он есть. Effect.forkChild запускает вопрос к книге в фоне, чтобы тест мог двигать часы, пока вопрос ждёт; файберы подробно разберём в EF7.

Часы это сервис, как провод. Рыдкулл в конце EF4 перечислял, что башня таскает руками: провод, журнал, часы. Часы Effect с самого начала брал из контекста, поэтому их и подменить можно без единой правки в башне.

Второе, что даёт @effect/vitest, это общий слой на блок тестов:

layer(testLayer([1, 2]))('один слой на весь блок', (it) => {
  it.effect('первый тест пишет в журнал', () =>
    Journal.use((journal) => journal.write('первый')),
  );
  it.effect('второй видит запись первого', () =>
    Effect.map(entries, (journal) => expect(journal).toEqual(['первый'])),
  );
});

layer(...) собирает слой один раз на блок и закрывает его после последнего теста. Это нужно, когда сборка дорогая: соединение с базой, загруженный справочник. Цена видна в примере: тесты блока делят состояние, и второй зависит от первого. Для журнала так обычно не делают, здесь это показ того, что слой правда один.

Книга, которая не держит дверь

Теперь вторая жалоба. Книга в проде грузится долго. Если грузить её в Layer.effect обычным yield*, сборка башни ждёт, пока книга не загрузится, и вся ночь стоит. Но книга нужна только для подтверждений Ack, а пинги и тексты приходят раньше.

Решение: слой собирается сразу, загрузка уходит в фон, а wasSent ждёт её, только когда его спросят. Ждать помогает Deferred:

// внутри class Ledger
static readonly layerLazy = (load: Effect.Effect<Iterable<number>>) =>
  Layer.effect(
    Ledger,
    Effect.gen(function* () {
      const ready = yield* Deferred.make<ReadonlySet<number>>();
      yield* Effect.forkScoped(
        Effect.flatMap(load, (sent) => Deferred.succeed(ready, new Set(sent))),
      );
      return Ledger.of({
        wasSent: (id) => Effect.map(Deferred.await(ready), (ids) => ids.has(id)),
      });
    }),
  );

Deferred.make заводит пустую ячейку. Effect.forkScoped запускает загрузку в фоне и привязывает её к жизни слоя: кончится программа, загрузку прервут, если она ещё идёт. Как именно, расскажет EF6. Загрузка кончилась, Deferred.succeed кладёт книгу в ячейку. wasSent ждёт ячейку через Deferred.await: первый вопрос может подождать, все следующие получат ответ сразу.

  4 мс  слой собран, смена началась
313 мс  книга ответила: true

Это настоящие часы и загрузка на 300 миллисекунд. Смена началась сразу, а ответа на вопрос о книге она ждала, сколько грузилась книга, и ни миллисекундой дольше.

Где тут дыра

const careless = Layer.succeed(Ledger, {} as any);
const exit = await Effect.runPromiseExit(Ledger.use((ledger) => ledger.wasSent(3)).pipe(Effect.provide(careless)));
TypeError: ledger.wasSent is not a function

Пока тест про пинги, as any молчит. Через месяц кто-нибудь поменяет checkFrame, и она начнёт спрашивать книгу на каждом кадре. Тест упадёт с TypeError: ledger.wasSent is not a function, без имени сервиса и без слова «мок». С Layer.mock тот же тест скажет bashnya/Ledger: Unimplemented method "wasSent", и сразу видно, что тест опирался на то, что книгу не спрашивают.

Вторая дыра Ринцвальда тише: в его тестах ленивой книги стоит настоящий Effect.sleep('3 seconds'), и каждый прогон тестов ждёт три секунды на тест. С it.effect и TestClock.adjust тот же тест идёт мгновенно и проверяет больше: не «когда-нибудь ответила», а «ответила ровно когда дочитала».

Башня, этап 18

У башни появляются два дублёра и одна ленивая книга. Провод с обрывами для тестов, книга, которая грузится в фоне и не держит сборку. Тест этапа написан на @effect/vitest: it.effect, TestClock, Layer.mock и общий слой на блок.

Практика

Дальше

Рыдкулл кладёт на стол два листа: заказ на завтрашнюю ночь и заказ на проверку.

Сундук стоит у двери и лязгает крышкой. Заказ ждёт в следующем уроке.