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

Триада сопровождаемости

«Хороший код» распадается на три измеримых свойства — читаемость (стоимость понять), изменяемость (стоимость безопасно изменить), тестируемость (стоимость проверить в изоляции); ревью называет, какое свойство изменение улучшает или ухудшает.

CP Middle ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

«Хороший код» — это комплимент, а не измерение. На ревью нельзя действовать по «мне это нравится» — нужно сказать, что именно хорошо и чем изменение пожертвовало. Прошлый урок дал экономическую призму: хороший код — это код, чьё следующее изменение дёшево. Но «дёшево менять» — это всё ещё одно расплывчатое число. Чтобы реально оценивать код, нужно разложить это число на силы, которые под ним лежат.

Их три, и это не одна и та же сила. Функция может быть идеально ясной для чтения и при этом невозможной для тестирования. Другая может тривиально тестироваться и при этом быть туманом для чтения. Назвать эти три свойства — и сказать, какое из них двигает изменение — это большая часть того, что отличает senior-комментарий на ревью от «по-моему, нормально».

Цель

После этого урока ты можешь назвать три свойства, из которых складывается сопровождаемость — читаемость (стоимость понять), изменяемость (стоимость безопасно изменить), тестируемость (стоимость проверить в изоляции); объяснить, почему они коррелируют, но разделяемы, на конкретной TS-функции, которая читаема, но нетестируема; использовать внедрение зависимостей, чтобы добавить тестируемость, не тратя читаемость; и ревьюить код так, как это делают senior — говоря, какое свойство изменение улучшает, а какое ухудшает, вместо «выглядит хорошо».

1

Сопровождаемость — не одно свойство, а три измеримых, разделяемых. «Дёшево менять» связывает в пучок три разные стоимости, и ты должен уметь оценивать код по каждой независимо:

  • Читаемость — стоимость понять, что этот код делает и зачем, с холодного старта. Измеряется тем, сколько компетентному читателю нужно времени, чтобы быть уверенным.
  • Изменяемость — стоимость безопасно изменить: сколько мест придётся тронуть, насколько велик радиус поражения, насколько ты можешь быть уверен, что не сломал вызывающего.
  • Тестируемость — стоимость проверить единицу в изоляции: можешь ли ты прогнать эту логику, не поднимая базу, не подменяя часы и не запуская всё приложение?

Это оси, по которым senior реально выставляет оценки. «Хороший код» — это высокие баллы по всем трём; «плохой код» обычно проваливает одну конкретную ось, и назвать, какую именно, — в этом весь смысл.

2

Они коррелируют, но разделяемы — и зазоры между ними и есть места, где прячутся баги. Свойства склонны двигаться вместе: хорошо названный, связный код обычно легко читать, менять и тестировать. Эта корреляция реальна, поэтому новички и относятся к «хорошему коду» как к чему-то одному. Но они расходятся достаточно часто, чтобы их смешение было senior-уровневой ошибкой:

// Читаемо. Очевидное намерение. Нетестируемо.
function isTrialExpired(user: User): boolean {
  const elapsedDays = (Date.now() - user.signupAt) / 86_400_000;
  return elapsedDays > 14;
}

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

3

Обычный виновник низкой тестируемости — скрытая зависимость: время, IO, случайность или глобальное состояние, к которым тянутся напрямую. Date.now(), Math.random(), process.env, fetch, fs.readFileSync, синглтон уровня модуля — каждое из них зависимость, которую функция хватает из окружающей среды вместо того, чтобы получить. Читается нормально, потому что зависимость невидима в сигнатуре. Именно эта невидимость и убивает тестируемость: тест может управлять только тем, что он способен передать, а ты не можешь передать то, к чему функция тянется у тебя за спиной.

// Читаемо и так же нетестируемо: тянет конфиг из окружающей среды.
function buildApiUrl(path: string): string {
  const base = process.env.API_BASE ?? "https://prod.example.com";
  return `${base}${path}`;
}

Чтобы протестировать ветку staging, пришлось бы менять process.env (глобальное, протекает между тестами) и не забыть его восстановить. Сигнатура (path: string) => string врёт: функция на деле зависит от path и от окружения. Нетестируемость — это обычно сигнатура, которая не говорит правду о своих входах.

4

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

// Та же логика, та же ясность — теперь детерминированно тестируемо.
function isTrialExpired(user: User, now: number): boolean {
  const elapsedDays = (now - user.signupAt) / 86_400_000;
  return elapsedDays > 14;
}

// Тест управляет временем. Без моков, без сна, без глобального состояния.
isTrialExpired({ signupAt: 0 }, 13 * 86_400_000); // false
isTrialExpired({ signupAt: 0 }, 20 * 86_400_000); // true

Прочитай ещё раз: понимать это ровно так же легко, как раньше. Мы не потратили ни капли читаемости и купили полную тестируемость. Это идеальный ход — и он показывает, что свойства разделяемы и в хорошую сторону: можно поднять одно, не платя другим. Стоимость вытолкнута наверх, к вызывающему (он обязан подать now: Date.now() один раз на краю), а это ровно то место, где реальным, нетестируемым часам и положено быть.

5

Режим отказа: погоня за одним свойством может угробить два других. Поскольку оси разделяемы, можно переоптимизировать одну и заплатить остальными. Классический случай — тестируемость, купленная мокингом и косвенностью:

// «Тестируемо» — но читаемость и изменяемость теперь угроблены.
class TrialChecker {
  constructor(
    private clock: IClock,
    private config: IConfig,
    private logger: ILogger,
    private featureFlags: IFeatureFlags,
  ) {}
  isExpired(user: User): boolean {
    this.logger.debug("checking");
    const limit = this.config.get("trialDays");
    if (!this.featureFlags.isOn("trials")) return false;
    return (this.clock.now() - user.signupAt) / 86_400_000 > limit;
  }
}

Каждый интерфейс существует «чтобы его можно было замокать», но теперь: прочитать правило — значит гнаться за четырьмя абстракциями; юнит-тесту нужно четыре мока только ради того, чтобы сравнить даты; а изменить правило — значит тронуть класс, его интерфейсы и настройку моков в каждом тесте. Тестируемость выросла на волосок, а читаемость и изменяемость рухнули. Senior-инстинкт: простой параметр now: number дал ту же тестируемость бесплатно. Косвенность — это стоимость, а не значение по умолчанию: добавляй шов только там, где живёт реальная, трудноуправляемая зависимость.

Разбор примера

Оцени изменение по всем трём осям, потом выбери ход, который поднимает одну, не утопляя другую. Вот функция, отправляющая чек по почте, написанная самым очевидным образом:

// до
async function sendReceipt(order: Order): Promise<void> {
  const id = `rcpt_${Math.random().toString(36).slice(2)}`;
  const sentAt = new Date().toISOString();
  await mailer.send(order.email, `Receipt ${id} at ${sentAt}`);
}

Оцени: читаемость в порядке — можно проследить. Тестируемость почти нулевая — три скрытые зависимости (Math.random, new Date, глобальный для модуля mailer) означают, что тест не может проверить id чека, не может зафиксировать метку времени и не может избежать реальной отправки письма. Изменяемость посредственная — к глобальному mailer тянутся напрямую, поэтому смена провайдера трогает и это место, и все соседние.

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

// после
async function sendReceipt(
  order: Order,
  deps: { genId: () => string; now: () => Date; mailer: Mailer },
): Promise<void> {
  const id = `rcpt_${deps.genId()}`;
  const sentAt = deps.now().toISOString();
  await deps.mailer.send(order.email, `Receipt ${id} at ${sentAt}`);
}

Тест теперь передаёт genId: () => "fixed", now: () => new Date(0) и фейковый mailer, который записывает вызов — полностью детерминированно, без реального письма, с точными проверками. Тестируемость ушла от почти нулевой к высокой. Изменяемость улучшилась: mailer стал параметром, поэтому смена его — это изменение проводки на краю, а не правка кода здесь. Читаемость не изменилась — три строки, та же форма, мешок deps называет ровно то, что варьируется. Мы намеренно остановились на трёх реальных зависимостях; мы не обернули каждую в собственный интерфейс, потому что это потратило бы читаемость на покупку тестируемости, которая у нас уже была.

Почему это работает

Зачем вообще называть три оси, а не просто «сопровождаемо»? Потому что единая оценка прячет сделку, которую ты заключаешь. Каждый реальный дизайнерский выбор поднимает одно свойство и рискует понизить другое, и единственный способ обсудить это честно на ревью — иметь слова для каждого. «Это выделение улучшает изменяемость, но стоит немного читаемости — оправданно, потому что это правило меняется ежемесячно» — это решение, которое команда может оценить. «Так чище» — нет. Словарь из трёх осей превращает ревью из вкуса в инженерию: ты заявляешь, какое свойство покупаешь и чем за него платишь.

Частая ошибка

Самое дорогое заблуждение — считать три за одно: предполагать, что читаемый код обязан быть тестируемым, или что добавление тестов доказывает, что код хорош. Они коррелируют, а не тождественны. Читаемо-но-нетестируемое (скрытые часы или IO) уезжает в прод постоянно и выглядит нормально на ревью именно потому, что хорошо читается. И обратная ловушка хуже: команды, которые приравнивают «тестируемое» к «хорошему», тянутся к мок-тяжёлой косвенности, топящей читаемость и изменяемость ради числа покрытия. Оценивай оси по отдельности — иначе пожертвуешь двумя, которые важнее всего, чтобы защитить ту, которую легче всего измерить.

Проверь себя
Викторина

Ревьюер говорит: «очень читаемо, мёржим». Функция вычисляет окно скидки, читая Date.now() напрямую. По триаде сопровождаемости — в чём именно проблема и каково самое дешёвое исправление?

Итог

Сопровождаемость раскладывается на три измеримых, разделяемых свойства: читаемость (стоимость понять), изменяемость (стоимость безопасно изменить) и тестируемость (стоимость проверить в изоляции). Они коррелируют — хороший код обычно хорош по всем трём, — но они расходятся, и в зазорах живёт senior-суждение: функция, которая читает Date.now(), process.env или глобальное состояние напрямую, читаема, но нетестируема, потому что скрытая зависимость заставляет её сигнатуру врать о входах. Внедрение этой зависимости (передать now, часы, мешок конфига) поднимает тестируемость, не трогая читаемость, — доказательство, что оси двигаются независимо. Режим отказа — гнаться за одной осью до обрыва: тестируемость, купленная мок-тяжёлой косвенностью, губит две другие. Поэтому ревьюй код, называя ось — говори, какое свойство изменение улучшает, а какое ухудшает, — и ты заменил «выглядит хорошо» инженерным аргументом.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.