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

Рефакторинг под тестируемость: швы, ядро и оболочка

middle-senior~50 мин

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

Рефакторинг под тестируемость: швы, ядро и оболочка

Сцена · запаянная коробка

В уроке 14 мы видели код, который нельзя протестировать: fetch и Date.now() зашиты прямо внутри, шва не подлезть. Тогда мы только показали пальцем и обещали вернуться. Возвращаемся.

В Kassa есть такой модуль: spool.ts, выгрузка платежей во внешнюю бухгалтерию. Он работает. И его невозможно проверить, не подняв сеть и не подкрутив системные часы. Это запаянная коробка: внутрь не подлезть, значит проверить нельзя. Этот урок про то, как вскрыть коробку, не сломав поведение: сделать швы, разделить чистое ядро и оболочку, вынести зависимости в порты. И главный тезис: рефакторинг под тестируемость это и есть рефакторинг под хорошую архитектуру.

Карта урока · что заберёшь

  • научишься узнавать непроверяемый код по зашитым зависимостям и побочкам в логике;
  • разделишь функцию на чистое ядро (functional core) и тонкую оболочку (imperative shell);
  • вынесешь внешнюю зависимость в порт, подменяемый в тесте;
  • увидишь, что в Effect подмена для теста и архитектура это один и тот же Layer.

Раздел 1 · как выглядит непроверяемый код

Идея словами

Код бывает рабочим и при этом непроверяемым. Признаки запаянной коробки:

  • зашитые зависимости: fetch, Date.now(), Math.random() вызываются прямо в функции, подменить нельзя;
  • смешаны чистое и грязное: расчёт (фильтр, сумма, формат) перемешан с побочками (лог, сеть) в одном цикле;
  • модульное мутируемое состояние: переменная уровня модуля, которую функция меняет;
  • нет шва: ни одной точки, куда можно вставить тестовый дублёр.

Шаг 1 · диагноз spool.ts

Вот реальный spool.ts, какой сгенерировал бы агент без оглядки на тестируемость:

// apps/kassa-api/src/legacy/spool.ts
let lastRunAt = 0; // модульное мутируемое состояние

export async function spoolPayments(raw: ReadonlyArray<RawPayment>): Promise<SpoolResult> {
  lastRunAt = Date.now(); // зашитое время

  const lines: string[] = [];
  let total = 0;

  for (const row of raw) {
    if (row === undefined || typeof row.amount !== 'number' || typeof row.account !== 'string') {
      continue;
    }
    if (row.amount <= 0) {
      console.log('spool: пропускаю некорректную строку', row); // побочка в логике
      continue;
    }
    total += row.amount;
    lines.push(`${row.account}\t${row.amount}\t${new Date(lastRunAt).toISOString()}`);
  }

  const report = `SPOOL ${new Date(lastRunAt).toISOString()}\n${lines.join('\n')}\nTOTAL\t${total}`;

  const response = await fetch('https://accounting.example.com/spool', { // зашитая сеть
    method: 'POST',
    body: report,
  });
  if (!response.ok) {
    throw new Error(`spool failed: ${response.status}`);
  }

  return { total, count: lines.length, sentAt: lastRunAt };
}

Чтобы проверить хотя бы “сумма считается верно”, надо поднять HTTP-мок (иначе fetch упадёт), подкрутить часы (иначе lastRunAt каждый раз другой) и стерпеть лог в консоль. Один маленький факт про арифметику требует трёх подмен глобалей. Это и есть цена запаянной коробки.

Картинка · где швы, которых нет

spoolPayments
  ├─ Date.now()        ← зашито, шва нет
  ├─ фильтр + сумма    ← чистый расчёт, но заперт внутри
  ├─ формат строк      ← чистый, заперт внутри
  ├─ console.log       ← побочка посреди логики
  └─ fetch(...)        ← зашито, шва нет

Чистое (фильтр, сумма, формат) и грязное (время, лог, сеть) слиплись в один кусок. Пока они вместе, проверить чистое без грязного нельзя.

Когда код пишет агент

Агент генерирует ровно такой код, потому что так короче: дёрнуть fetch на месте проще, чем заводить сервис; взять Date.now() проще, чем принимать время аргументом. Короткий код выигрывает у тестируемого, если никто не задал тестируемость как требование. В Effect-проекте диагноз ставится по сигнатуре, об этом ниже.

Что взять с собой

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

Раздел 2 · functional core, imperative shell

Идея словами

Первый и главный приём: functional core, imperative shell. Раздели функцию надвое:

  • чистое ядро (functional core): вся логика (фильтр, сумма, формат), но время и прочие входы приходят аргументами. Никаких Date.now, fetch, лога, мутаций. Тестируется юнитами мгновенно и без подмен.
  • тонкая оболочка (imperative shell): собирает входы из реального мира (взять время, отправить отчёт) и зовёт ядро. Кода в ней мало, и он почти весь про побочки.

Логика, которой много и где живут баги, уезжает в ядро и становится дешёвой в тестировании. Побочки, которых мало, остаются в оболочке и проверяются тонким интеграционным тестом.

Шаг 1 · выделяем чистое ядро

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

// apps/kassa-api/src/legacy/spool-refactored.ts
export const buildSpoolReport = (raw: ReadonlyArray<RawPayment>, now: number): SpoolReport => {
  const valid = raw.filter(
    (row): row is { account: string; amount: number } =>
      typeof row.account === 'string' && typeof row.amount === 'number' && row.amount > 0,
  );

  const stamp = new Date(now).toISOString();
  const total = valid.reduce((sum, row) => sum + row.amount, 0);
  const lines = valid.map((row) => `${row.account}\t${row.amount}\t${stamp}`);
  const report = `SPOOL ${stamp}\n${lines.join('\n')}\nTOTAL\t${total}`;

  return { report, total, count: valid.length };
};

Теперь арифметику и формат проверяет обычный AAA-юнит, без сети и без часов:

// apps/kassa-api/test/spool-refactored.test.ts
it('считает сумму и счётчик, отбрасывая некорректные строки', () => {
  const { total, count } = buildSpoolReport(
    [
      { account: 'a', amount: 30 },
      { account: 'b', amount: 70 },
      { account: 'c', amount: 0 },
      { amount: 10 },
    ],
    0,
  );

  expect(total).toBe(100);
  expect(count).toBe(2);
});

it('формат отчёта детерминирован по переданному времени', () => {
  const { report } = buildSpoolReport([{ account: 'a', amount: 30 }], 1_700_000_000_000);

  expect(report).toContain('SPOOL 2023-11-14');
  expect(report).toContain('TOTAL\t30');
});

Где раньше нужны были HTTP-мок и подкрутка часов, теперь два чистых юнита. Время детерминировано, потому что мы его передаём, а не берём из мира. Это детерминизм через данные, доведённый до архитектуры.

Что взять с собой

  • раздели функцию на чистое ядро (логика, входы аргументами) и тонкую оболочку (побочки);
  • ядро проверяется юнитами мгновенно и без подмен;
  • передавай время аргументом, и тест станет детерминированным сам собой.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.