open atlas
↑ К треку
Паттерны и качество кода CP · 10 · 02

Швы

Шов — это место, где поведение юнита можно подменить, не редактируя его прямо там: внедрив часы, репозиторий или параметр. Так упрямый легаси становится тестируемым через малую локальную правку, а не рискованное переписывание.

CP Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Тебе нужно обложить тестом пятилетнюю биллинговую функцию, прежде чем её трогать. Ты открываешь её — и почва уходит из-под ног: где-то посередине она вызывает new Date(), читает глобальный синглтон конфига и пишет прямо в базу. Никак не спросить «что она вернёт в последний день февраля?», не дождавшись реально этого дня, не мутировав общее состояние и не подняв базу. Функция работает в проде — но она приварена к реальному миру, и ухватиться за неё не за что.

Инстинкт — переписать её: вынести сервисный слой, протащить интерфейсы всюду, перекроить модуль. Часто это неверный ход и реальный риск — ты бы менял код, на который у тебя ещё нет теста. Ответ Майкла Физерса меньше и хирургичнее: найди шов — место, где поведение можно изменить не редактируя его прямо там, — и через него подсунь код под тест первым. А потом рефактори со страховкой.

Цель

После этого урока ты можешь определить шов как точку, где поведение можно подменить, не редактируя там, вместе с его точкой включения; распознать три классических типа швов (объектный, параметрический и языковой/линковочный) и выбрать наименее инвазивный; превратить вшитый new Date(), глобал или прямой вызов БД в шов, чтобы юнит стал тестируемым; и объяснить, почему именно умение видеть швы — а не просто делать внедрение зависимостей — это ключевой навык укрощения легаси, и когда шов — неподходящий инструмент.

1

Шов — это место, где можно изменить поведение, не редактируя его прямо там. Это определение (из книги Физерса Working Effectively with Legacy Code) точное и держать его стоит дословно. Смысл шва — не «хороший дизайн» в абстракции, а рычаг: точка, где ты, тестировщик, можешь подставить другое поведение во время выполнения, не трогая строки, которые делают работу. У каждого шва есть точка включения — место, где решается, какое поведение активно (аргумент конструктора, параметр, флаг сборки).

У вшитого кода швов нет. Когда функция буквально содержит new Date() или Database.query(...), нет места, чтобы встать снаружи и сказать «используй вместо этого вот это». Вся игра в том, чтобы обложить легаси тестами, сводится к: найти или создать шов, затем им воспользоваться.

2

Самый частый шов — объектный: внедри коллаборатора вместо того, чтобы конструировать его. Возьмём юнит, приваренный к часам:

// нет шва — поведение приварено к реальным часам
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 — лишь один вид шва.

3

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

  • Объектный шов — подмени коллаборатора (Clock/Repository выше). Точка включения: конструктор или параметр. Лучший дефолт в TS.
  • Параметрический шов — передай само меняющееся значение, а не целый объект: isTrialExpired(user, now: Date). Ещё меньше, чем внедрять Clock, когда тебе нужно лишь одно показание.
  • Шов «подкласс и переопределение» — оберни вшитый вызов в protected-метод, затем переопредели его в тестовом подклассе. Полезно, когда нельзя менять сигнатуру (это хук фреймворка или слишком много вызывающих).
  • Линковочный/языковой шов — подмени на этапе разрешения модулей или сборки (тестовый дублёр для импортируемого модуля, например мок ./db в тестовом раннере). Точка включения целиком вне исходного кода.

Senior-ход — тянуться к наименее инвазивному шву, разблокирующему тест, а не к самому архитектурно чистому. Параметрический шов, который ты добавишь за тридцать секунд, бьёт иерархию интерфейсов, на которую ты потратишь день.

4

Тот же приём укрощает глобалы и прямой ввод-вывод — шов там, где юнит тянется наружу. Глобальный синглтон конфига и прямой 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.