Рефакторинг под тестируемость: швы, ядро и оболочка
Сцена · запаянная коробка
В уроке 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.