Подмена зависимостей: vi.mock, ручной DI, Effect-слой
Сцена · розетка против припаянного провода
Представь два прибора. У первого шнур припаян прямо к проводке в стене: чтобы заменить прибор, надо лезть в стену с паяльником. У второго обычная вилка и розетка: выдернул, воткнул другой, готово. А у тебя дома ещё есть распределительный щиток, одна точка, где решается, что куда подключено.
Зависимость в коде это ровно та же история. Зашитый fetch внутри функции это припаянный провод: чтобы подменить его в тесте, надо менять саму функцию. Зависимость-аргумент это розетка. А Effect-слой это распределительный щиток: одна точка, где ты решаешь, что куда воткнуто, и для прода и для теста просто разные щитки. Этот урок про три способа сделать зависимость подменяемой и про то, почему один из них особенный.
Карта урока · что заберёшь
- поставишь рядом три подхода к подмене:
vi.mock, ручной DI, Effect-слой; - сравнишь их по хрупкости и читаемости на одном примере;
- докажешь тезис “подмена для теста и сборка приложения это один механизм”;
- научишься видеть неподменяемый код по сигнатуре и понимать, почему агент его пишет.
Раздел 1 · три способа подменить зависимость
Идея словами
Подменить можно одну и ту же зависимость (курс валют) тремя способами, от хрупкого к удобному:
- module mocking (
vi.mock): подменяешь модуль целиком на уровне импортов. Работает без рефакторинга кода, но хрупко: привязано к путям импорта, легко мокнуть лишнее. - ручной DI: зависимость приходит аргументом или полем. Честно и явно, но тащить её руками через все слои утомительно.
- Effect-слой: зависимость это сервис в
Context, подмена это другойLayer. И это тот же механизм, что собирает приложение в проде.
Картинка · где живёт дубль
Тот же виджет, что в прошлом уроке, но смотри теперь на правую колонку “в Effect”: каждый дубль это не отдельная конструкция, а реализация порта, поданная слоем. Это и есть подсказка, к чему мы придём в третьем разделе.
Шаг 1 · module mocking
import { expect, it, vi } from 'vitest';
vi.mock('~/rates-client.ts', () => ({
getRate: () => 75, // подменили весь модуль
}));
it('конвертирует по подменённому курсу', async () => {
const result = await convert(money(100), 'USD', 'RUB');
expect(result).toBe(7500);
});
Плюс: ничего не рефакторил, мокнул и поехал. Минусы серьёзные: строка '~/rates-client.ts' это путь, который сломается при переезде файла; подмена глобальна на весь файл теста; легко случайно замокать и то, что мокать не собирался. Это самый хрупкий вариант.
Шаг 2 · ручной DI
it('конвертирует по переданному курсу', async () => {
const rates = { getRate: () => 75 }; // зависимость как аргумент
const result = await convert(money(100), 'USD', 'RUB', rates);
expect(result).toBe(7500);
});
Честнее: зависимость видна в сигнатуре, подмена локальна, никаких путей. Цена в том, что rates приходится протаскивать через каждый промежуточный вызов, и в большом дереве вызовов это превращается в гирлянду параметров.
Задача · зависимость приходит аргументом
Тот самый ручной DI, только руками и до конца: часы и отправка приезжают снаружи, поэтому тест подменяет их обычным замыканием, без единой библиотеки.
Что взять с собой
vi.mockне требует рефакторинга, но хрупок и привязан к путям;- ручной DI явный и локальный, но параметр тащится через все слои;
- оба решают одну задачу: дать зависимости шов.
Раздел 2 · Effect-слой: подмена это композиция
Идея словами
В Effect зависимость это сервис-тег в Context, а реализация приходит Layer-ом. И вот главный тезис всей серии: тестовый слой не отдельная мок-инфраструктура, а такой же Layer, как боевой. В Kassa это видно буквально.
Шаг 1 · боевой и тестовый слой одного порта
Боевой курс валют ходит в сеть:
// apps/kassa-api/src/layers/services-live.ts
import { Effect, Layer } from 'effect';
import { HttpClient } from 'effect/http';
export const RatesHttpLive = Layer.effect(
Rates,
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return Rates.of({
getRate: (from, to) => client.get(/* ...внешний API... */),
});
}),
);
Заметь путь импорта: HTTP-клиент лежит в effect/http, внутри самого пакета effect. Раньше платформенные модули жили отдельным пакетом, теперь они часть ядра, и версия у всей экосистемы одна.
Тестовый отдаёт фиксированный курс:
// apps/kassa-api/src/layers/test.ts
export const RatesTestLive = (rate = 1): Layer.Layer<Rates> =>
Layer.succeed(
Rates,
Rates.of({ getRate: (from, to) => (from === to ? Effect.succeed(1) : Effect.succeed(rate)) }),
);
Заметь: RatesTestLive это такой же Layer, как RatesHttpLive. Не “мок”, не особая тестовая машинерия, а вторая реализация того же тега Rates.
Шаг 2 · меняется одна строка
// прод
const program = handleConvert(req).pipe(Effect.provide(RatesHttpLive));
// тест
const program = handleConvert(req).pipe(Effect.provide(RatesTestLive(75)));
Сама программа handleConvert не меняется ни на символ. Меняется только то, какой слой ты в неё provide-нул. Это и есть тезис: в Effect подмена зависимости для теста и сборка приложения для прода это один и тот же механизм (Layer плюс Context). Ты не вводишь мок-инфраструктуру отдельно от продакшн-кода, ты просто даёшь другой щиток.
Это прямое продолжение тезиса урока про тестируемую архитектуру: тестируемость и есть хорошая архитектура. Только в Effect это совпадение выполняется по построению, а не усилием воли. И это та же мысль, что в уроке про fakes: in-memory fake тоже был просто Layer.
Шаг 3 · ещё и сетевой край: где Effect не помогает
Честно отметим границу. Тестовый Layer хорош, когда край это сервис внутри приложения. Но когда край это реальный HTTP по сети (а не наш сервис), иногда удобнее перехватить именно сетевой запрос инструментом вроде MSW, не трогая код. Где именно проходит граница “подменяй слоем” и “перехватывай сеть”, разберём в уроке про API и контракты и на практике с Postgres через testcontainers.
Что взять с собой
- в Effect зависимость это сервис, подмена это другой
Layer; - тестовый и боевой слой реализуют один тег, программа не меняется;
- подмена для теста и сборка для прода это один механизм.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.