Швы
Шов — это место, где поведение юнита можно подменить, не редактируя его прямо там: внедрив часы, репозиторий или параметр. Так упрямый легаси становится тестируемым через малую локальную правку, а не рискованное переписывание.
Тебе нужно обложить тестом пятилетнюю биллинговую функцию, прежде чем её трогать. Ты открываешь её — и почва уходит из-под ног: где-то посередине она вызывает new Date(), читает глобальный синглтон конфига и пишет прямо в базу. Никак не спросить «что она вернёт в последний день февраля?», не дождавшись реально этого дня, не мутировав общее состояние и не подняв базу. Функция работает в проде — но она приварена к реальному миру, и ухватиться за неё не за что.
Инстинкт — переписать её: вынести сервисный слой, протащить интерфейсы всюду, перекроить модуль. Часто это неверный ход и реальный риск — ты бы менял код, на который у тебя ещё нет теста. Ответ Майкла Физерса меньше и хирургичнее: найди шов — место, где поведение можно изменить не редактируя его прямо там, — и через него подсунь код под тест первым. А потом рефактори со страховкой.
После этого урока ты можешь определить шов как точку, где поведение можно подменить, не редактируя там, вместе с его точкой включения; распознать три классических типа швов (объектный, параметрический и языковой/линковочный) и выбрать наименее инвазивный; превратить вшитый new Date(), глобал или прямой вызов БД в шов, чтобы юнит стал тестируемым; и объяснить, почему именно умение видеть швы — а не просто делать внедрение зависимостей — это ключевой навык укрощения легаси, и когда шов — неподходящий инструмент.
Шов — это место, где можно изменить поведение, не редактируя его прямо там. Это определение (из книги Физерса Working Effectively with Legacy Code) точное и держать его стоит дословно. Смысл шва — не «хороший дизайн» в абстракции, а рычаг: точка, где ты, тестировщик, можешь подставить другое поведение во время выполнения, не трогая строки, которые делают работу. У каждого шва есть точка включения — место, где решается, какое поведение активно (аргумент конструктора, параметр, флаг сборки).
У вшитого кода швов нет. Когда функция буквально содержит new Date() или Database.query(...), нет места, чтобы встать снаружи и сказать «используй вместо этого вот это». Вся игра в том, чтобы обложить легаси тестами, сводится к: найти или создать шов, затем им воспользоваться.
Самый частый шов — объектный: внедри коллаборатора вместо того, чтобы конструировать его. Возьмём юнит, приваренный к часам:
// нет шва — поведение приварено к реальным часам
function isTrialExpired(user: User): boolean {
const now = new Date(); // ← нельзя подменить из теста
return now.getTime() > user.trialEndsAt.getTime();
}Нельзя детерминированно протестировать «истёк» против «активен»: ответ зависит от настенных часов. Вводим объектный шов, передавая зависимость внутрь:
interface Clock { now(): Date }
const systemClock: Clock = { now: () => new Date() };
function isTrialExpired(user: User, clock: Clock = systemClock): boolean {
return clock.now().getTime() > user.trialEndsAt.getTime();
}Шов — это параметр clock; точка включения — место вызова (прод не передаёт ничего и получает systemClock; тест передаёт { now: () => new Date('2026-01-01') }). Заметь, что значение по умолчанию оставляет всех существующих вызывающих нетронутыми — изменение аддитивно, а не переписывание. Это ровно внедрение зависимостей, но DI — лишь один вид шва.
Швы бывают разных видов, и побеждает самый дешёвый. Физерс классифицирует их по тому, где живёт точка включения. Знание меню позволяет выбрать наименьший разрез:
- Объектный шов — подмени коллаборатора (
Clock/Repositoryвыше). Точка включения: конструктор или параметр. Лучший дефолт в TS. - Параметрический шов — передай само меняющееся значение, а не целый объект:
isTrialExpired(user, now: Date). Ещё меньше, чем внедрятьClock, когда тебе нужно лишь одно показание. - Шов «подкласс и переопределение» — оберни вшитый вызов в
protected-метод, затем переопредели его в тестовом подклассе. Полезно, когда нельзя менять сигнатуру (это хук фреймворка или слишком много вызывающих). - Линковочный/языковой шов — подмени на этапе разрешения модулей или сборки (тестовый дублёр для импортируемого модуля, например мок
./dbв тестовом раннере). Точка включения целиком вне исходного кода.
Senior-ход — тянуться к наименее инвазивному шву, разблокирующему тест, а не к самому архитектурно чистому. Параметрический шов, который ты добавишь за тридцать секунд, бьёт иерархию интерфейсов, на которую ты потратишь день.
Тот же приём укрощает глобалы и прямой ввод-вывод — шов там, где юнит тянется наружу. Глобальный синглтон конфига и прямой db.query — та же проблема, что и new Date(): юнит называет конкретную вещь из реального мира вместо того, чтобы принимать абстракцию.
// нет шва — читает глобал и бьёт по реальной БД
async function chargeOverdue(): Promise<number> {
const limit = GlobalConfig.get('overdueLimit'); // глобал
const rows = await db.query('SELECT * FROM invoices WHERE overdue = true'); // реальная БД
return rows.filter((r) => r.amount > limit).length;
}// швы: конфиг и репозиторий передаются внутрь
interface InvoiceRepo { listOverdue(): Promise<Invoice[]> }
async function chargeOverdue(repo: InvoiceRepo, limit: number): Promise<number> {
const rows = await repo.listOverdue();
return rows.filter((r) => r.amount > limit).length;
}Теперь тест передаёт repo, возвращающий три фиксированных счёта, и limit, и проверяет счётчик — никакой базы, никакого глобала. Логика функции (фильтр и подсчёт) всегда была интересной частью; швы просто дали к ней дотянуться. Заметь, мы не «проектировали слой персистентности» — мы вынесли ровно те две зависимости, что нужны тесту, и остановились.
Разблокируй упрямый юнит наименьшим возможным швом. Легаси-функция решает, слать ли напоминание «продли сейчас». Она приварена к часам и к почтовому шлюзу:
// до — не тестируется: реальные часы, реальная сеть
function maybeSendRenewalNudge(sub: Subscription): void {
const daysLeft =
(sub.expiresAt.getTime() - Date.now()) / 86_400_000;
if (daysLeft <= 7 && daysLeft > 0) {
EmailGateway.send(sub.email, 'Renew now'); // реальная отправка
}
}Нельзя проверить «шлёт за 7 дней, молчит за 8», не путешествуя во времени и не отправляя реальное письмо. Соблазнительная сверхреакция — ввести интерфейс NotificationService, ClockProvider, DI-контейнер и перепаять модуль. Ничего из этого не нужно, чтобы получить тест. Добавь ровно два шва — параметрический для времени и объектный для шлюза — оба со значениями по умолчанию, чтобы вызывающие не сломались:
// после — два малых, аддитивных шва
interface Mailer { send(to: string, subject: string): void }
const liveMailer: Mailer = { send: EmailGateway.send };
function maybeSendRenewalNudge(
sub: Subscription,
mailer: Mailer = liveMailer,
now: number = Date.now(), // параметрический шов: меньше, чем объект Clock
): void {
const daysLeft = (sub.expiresAt.getTime() - now) / 86_400_000;
if (daysLeft <= 7 && daysLeft > 0) {
mailer.send(sub.email, 'Renew now');
}
}Теперь тест тривиален и детерминирован:
test('nudges inside the 7-day window, stays silent outside it', () => {
const sent: string[] = [];
const spy: Mailer = { send: (to) => sent.push(to) };
const now = new Date('2026-06-01T00:00:00Z').getTime();
const inWindow = { email: 'a@x.com', expiresAt: new Date('2026-06-05T00:00:00Z') };
const outWindow = { email: 'b@x.com', expiresAt: new Date('2026-06-20T00:00:00Z') };
maybeSendRenewalNudge(inWindow, spy, now);
maybeSendRenewalNudge(outWindow, spy, now);
expect(sent).toEqual(['a@x.com']); // ровно один, тот что в окне
});Места вызова в проде не изменились: maybeSendRenewalNudge(sub) по-прежнему использует живой mailer и реальный Date.now(). Вся правка локальна для сигнатуры одной функции. С зелёным тестом ты заслужил право свободно рефакторить тело — страховка на месте. Это и есть рабочий процесс швов: шов → характеризующий тест → рефакторинг, никогда рефакторинг → надежда.
▸Почему это работает
Почему «видеть швы», а не «делать внедрение зависимостей», — настоящий навык? Потому что DI называет лишь случай объектного шва и подразумевает, что тебе позволят перепроектировать конструирование — чего на реальном легаси часто нельзя. Шов — идея шире: любой рычаг, позволяющий варьировать поведение без редактирования работы. Иногда этот рычаг — значение параметра по умолчанию, иногда protected-метод, переопределяемый в тестовом подклассе, иногда мок уровня модуля на линковочном шве — без всякого конструктора или контейнера. Senior-инженеры, укрощая легаси, осматривают упрямый юнит и спрашивают: «где самое дешёвое место, в котором я уже могу подменить поведение или открыть его крошечной правкой?» Ответ часто не учебниковое переписывание под DI, и понимание этого удерживает часовую задачу от превращения в недельную.
▸Частая ошибка
Классический режим провала: тянуться к большому, инвазивному переписыванию ради тестируемости, когда малый локальный шов разблокировал бы тест. Ты видишь new Date() и db.query и начинаешь проектировать интерфейс ClockProvider, слой репозиториев, адаптер на каждую таблицу и DI-контейнер — перекраивая код, у которого ещё нет тестов, то есть ровно то изменение, под которое у тебя нет страховки. Можно потратить день, внести новые баги и так и не получить тот единственный тест, ради которого ты пришёл. Переверни: добавь минимальный шов (параметр со значением по умолчанию, внедрённого коллаборатора в одну функцию), напиши характеризующий тест, доведи до зелёного, затем решай, стоит ли вообще больший рефакторинг. Часто, как только юнит тестируем, грандиозная переделка оказывается лишней — шов был всей победой.
Легаси-функция вызывает new Date() и глобальный синглтон конфига, и у неё нет тестов. Тебе нужно юнит-тестировать её ветвления. Какой senior-ход первым?
Шов — это место, где можно изменить поведение юнита, не редактируя его прямо там, управляемое из точки включения, — и поиск швов есть ключевой навык укрощения упрямого легаси под тестами. У вшитых вызовов вроде new Date(), глобального синглтона или прямого db.query шва нет, поэтому их нельзя подменить из теста; ты вводишь его, внедряя коллаборатора (объектный шов), передавая значение (параметрический шов), переопределяя protected-метод (подкласс и переопределение) или заменяя модуль на этапе линковки. Senior-суждение — выбрать наименее инвазивный шов, обычно параметр со значением по умолчанию или одну внедрённую зависимость, оставив правку аддитивной, чтобы существующие вызывающие не сломались; а режим провала, которого надо избегать, — запуск большого архитектурного переписывания ради тестируемости до того, как у тебя есть хоть один тест. Рабочий процесс: шов → характеризующий тест → рефакторинг, именно в этом порядке, чтобы каждое последующее изменение делалось со страховкой.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.