Я делаю MMORPG, которая живёт внутри Telegram: TypeScript, React, PostgreSQL, серверный расчёт боя. Сейчас это 101 тысяча строк, 176 миграций базы и 186 тестовых файлов, в которых 1819 проверок. Прогон зелёный, CI зелёный, релиз уходит в прод.

И примерно раз в две недели я нахожу очередной тест, который был зелёным по причине, к делу отношения не имеющей.

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

Ниже — семь таких случаев из живого проекта, с кодом и разбором, чем именно каждый был плох. Все настоящие, все стоили времени, у каждого есть дата в git. В конце — про то, чем я в итоге начал ловить этот класс ошибок системно, и во что это обошлось по времени прогона.

1. Колесо мыши, которое убил мой собственный фикс

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

Симптом: в Telegram Desktop игра не прокручивается колесом мыши. Совсем. Ползунок скроллбара тащится, PageDown работает, клики работают, на телефоне всё в порядке — а колесо мёртвое на любом экране.

Первое подозрение, естественно, на Telegram: мини-приложение живёт в webview под чужой оболочкой, у неё свой SDK, свои жесты, и есть даже метод disableVerticalSwipes(), который я вызываю, чтобы игру не сворачивали смахиванием вниз. Виновник напрашивается сам.

Я проверил его — и вот здесь совершил ошибку, которая стоила почти всего потерянного времени. Проверка была такая: повесить слушателя на wheel, покрутить колесо и посмотреть, не отменяет ли кто событие.

// так проверять НЕЛЬЗЯ — эта проверка отвечает не на тот вопрос
window.addEventListener("wheel", (e) => {
  console.log("wheel дошёл, defaultPrevented =", e.defaultPrevented);
});

В консоли было ровно то, что я хотел увидеть: событие доходит, defaultPrevented === false, никто ничего не отменяет. Вывод напрашивался: раз событие живо и не отменено — прокрутке ничто не мешает, значит дело в оболочке Telegram.

Вывод был неверный, потому что вопрос был неверный. defaultPrevented === false означает «событие не отменили обработчиком». Оно не означает «прокрутка произошла». Между «событие дошло» и «страница сдвинулась» лежит вся механика цепочки прокрутки браузера, и сломать её можно, не трогая события вообще — одним CSS.

Именно это и случилось. Двумя неделями раньше я закрывал другой дефект: в Telegram игру закрывали случайным смахиванием вниз. Лечится это свойством overscroll-behavior, которое запрещает прокрутке «протекать» из контейнера наружу:

html,
body {
  overscroll-behavior: none;
}
.overflow-y-auto,
.overflow-auto {
  overscroll-behavior: contain;
}

Смысл contain: докрутил контейнер до конца — дальше цепочка обрывается, родитель не прокручивается, оболочка не ловит жест, игра не сворачивается. Ровно то, что нужно на телефоне.

Беда в том, что у нас раскладка на min-h-screen: контейнер с классом overflow-y-auto вырастает на всю высоту контента и прокручивать внутри себя ему нечего — прокручивается страница целиком. Получается конструкция, где контейнер сам не скроллится, а передать прокрутку наверх ему запрещено. Тач и клавиши до страницы добираются другим путём и продолжают работать. Колесо — нет.

Тринадцать дней дефект жил незамеченным, потому что телефон — основная платформа, и там всё было нормально.

Развязка выглядела так: я воспроизвёл дефект вообще без Telegram — стендом Playwright на системном Edge и на комплектном Chromium, чистая страница, никакой оболочки. Колесо не крутит. После этого виновник нашёлся за десять минут.

Починка — вернуть цепочку тем, у кого есть мышь, оставив защиту тач-устройствам:

/* Свайп-защита нужна только тач-устройствам. На мыши цепочку возвращаем;
   выход жеста ЗА страницу и на десктопе продолжает глушить none на html/body. */
@media (hover: hover) and (pointer: fine) {
  .overflow-y-auto,
  .overflow-auto {
    overscroll-behavior: auto;
  }
}

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

const ctx = await browser.newContext({ viewport: { width: 420, height: 740 }, hasTouch: false });
const page = await ctx.newPage();
await toRaceScreen(page);

const before = await page.evaluate(() =>
  Math.max(window.scrollY, document.documentElement.scrollTop, document.body.scrollTop),
);
await page.mouse.move(210, 400);
await page.mouse.wheel(0, 800);
await page.waitForTimeout(400);
const after = await page.evaluate(() =>
  Math.max(window.scrollY, document.documentElement.scrollTop, document.body.scrollTop),
);

expect(before, "стартуем с верха страницы").toBe(0);
expect(
  after,
  "колесо обязано прокрутить страницу — если 0, вернулся дефект contain-цепочки",
).toBeGreaterThan(0);

Здесь важны две вещи, которые я раньше делал небрежно.

Первая: page.mouse.wheel() в Playwright — это не синтетическое событие, а команда протокола, браузер обрабатывает её как настоящий ввод, со всей цепочкой прокрутки. Синтетический dispatchEvent(new WheelEvent("wheel")) прошёл бы мимо дефекта, потому что он вообще не двигает страницу — по спецификации не должен.

Вторая: замер «до» здесь не украшение. Без expect(before).toBe(0) тест был бы зелёным и в случае, когда страница уже прокручена чем-то посторонним, — снова совпадение по неверной причине, только в другом месте.

И парный тест на тач-профиль, чтобы починка не отменила исходную защиту:

const m = await page.evaluate(() => ({
  pointerFine: matchMedia("(pointer: fine)").matches,
  ov: getComputedStyle(document.querySelector(".overflow-y-auto")!).overscrollBehaviorY,
}));
expect(m.pointerFine, "тач-эмуляция обязана давать pointer:coarse — иначе проба слепа").toBe(false);
expect(m.ov, "на таче контейнер обязан держать contain").toBe("contain");

Строка про pointerFine — та самая защита от собственной слепоты. Если эмуляция устройства однажды перестанет давать pointer: coarse, второй expect начнёт проверять десктопный режим вместо мобильного и будет радостно зеленеть, ничего не сторожа. Проверка условий, при которых проверка вообще имеет смысл, — привычка, которую я вынес из всей этой истории.

Стоимость: тринадцать дней в проде, полдня диагностики и один увод следствия на невиновного подозреваемого — Telegram, который в итоге оказался ни при чём.

2. Подстрока, которая совпала с другой подстрокой

Случай простой, но он в проекте повторялся столько раз, что превратился в отдельное правило.

В игре есть уровни сложности подземелий: «Обычный», «Героический», «Эпохальный» и «Эпохальный+». Замок проверял, что игроку показывается словесная причина отказа, и делал это так:

expect(message).toContain("Эпохальный");

Проверка зелёная. Только «Эпохальный» — подстрока «Эпохального+», и тест одинаково доволен обоими. То есть он не отличает две разные сложности с разными правилами входа, ради которых и писался.

Отдельно неприятно, что чем аккуратнее сделан текст в интерфейсе, тем вероятнее такое совпадение: названия в одной системе именования нарочно похожи друг на друга.

Лечится либо якорями, либо сравнением целиком:

expect(message).toMatch(/Эпохальный(?!\+)/);

Тот же дефект в чистом виде я поймал на числах. Проверка боевой шкалы выглядела как /gear-score 24/ — и совпадала с «gear-score 240» тоже. То есть оставалась зелёной при любом переводе шкалы, ради контроля которого и была написана.

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

Общее правило, которое я из этого вывел: toContain и шаблон по человеческому тексту — это не проверка, а намёк. Если совпадения достаточно, чтобы тест был зелёным, стоит спросить себя, какое ещё сообщение даст такое же совпадение. Обычно ответ находится сразу. А спецсимволы в шаблонах из готовых фраз надо экранировать — на кириллице это ещё и не единственная ловушка: \b в JavaScript с русскими буквами не работает вовсе, из-за чего у нас три прогона аудита текстов подряд дали неверный счёт.

3. Мутант оставил имя переменной — и замок промолчал

Здесь я впервые поймал класс ошибки не глазами, а инструментом.

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

Первая редакция замка выглядела так:

expect(COMBAT).toContain("critLook(");
expect(COMBAT).toContain("look.text");
expect(COMBAT).toContain("look.mark");
expect(COMBAT).toContain("look.flash"); // ← вот эта строка и была дырой

Проверка казалась разумной: если в файле есть look.flash, значит вспышка берётся из общего модуля.

Затем я прогнал мутационную проверку — она портит код и смотрит, заметит ли хоть один тест. Мутант убрал класс вспышки из разметки, оставив упоминание look.flash в условии показа. Все тесты остались зелёными. То есть в реальности эффект бы не отображался, а замок этого не видел: имя-то в файле осталось.

Починка — проверять не упоминание, а подстановку в разметку:

// ⚠️ Вспышку проверяем НЕ упоминанием, а ПОДСТАНОВКОЙ В РАЗМЕТКУ. Первая
// редакция искала просто «look.flash» — и мутант, убравший класс из разметки,
// прошёл мимо неё: имя осталось в условии показа, и замок промолчал.
expect(COMBAT).toContain("${look.flash}");

И рядом — контроль промахом, который я теперь пишу почти везде:

it("прежний вшитый цвет крита из боя убран", () => {
  // Пока старая строка жива, «зовёт critLook» могло значить «зовёт и не использует».
  expect(COMBAT).not.toContain('isCrit\n      ? "text-fuchsia-300"');
  expect(COMBAT).not.toContain('{isCrit && "✦ "}');
});

Мысль простая. Проверка «нужное на месте» сама по себе не доказывает, что нужное работает: старый код может лежать рядом и побеждать. Пара «новое есть» + «старого больше нет» доказывает существенно больше, а стоит одну лишнюю строку.

4. Разбор типа, который упёрся в комментарий

Ещё один замок, читающий исходники, — и ещё одна неверная причина, на этот раз в самом разборе.

Проверялось, что серверная функция не отдаёт клиенту лишних идентификаторов. Тест вырезал из файла объявление типа ответа и смотрел, какие поля в нём перечислены. Границу блока разбор искал по первому */.

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

Формально тест был зелёным, а фактически он проверял первые три строки типа.

Отсюда — правило, которое звучит скучно, но экономит часы: если тест разбирает исходный текст регуляркой, у него обязана быть проверка на самого себя. Хотя бы такая: «в разобранном куске найдено не меньше N полей» — тогда сломавшийся разбор краснеет сразу, а не притворяется успешной проверкой.

5. Комментарий в SQL, прочитанный как код

Свежий случай, буквально этой недели, и он про то же самое, но в базе.

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

И вот сверка сообщает: в базе отсутствует индекс с именем if.

Индекса с таким именем никогда не существовало. Полчаса я искал, откуда он взялся, — и нашёл в шапке собственной миграции. Шапки у нас длинные, они объясняют «зачем», и в них попадаются примеры SQL:

-- Идемпотентно: ADD COLUMN IF NOT EXISTS + CREATE UNIQUE INDEX IF NOT EXISTS.
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS vk_user_id bigint;
CREATE UNIQUE INDEX IF NOT EXISTS profiles_vk_user_id_key
  ON public.profiles (vk_user_id);

Регулярка вида create\s+(?:unique\s+)?index\s+(?:if\s+not\s+exists\s+)?(\w+) честно нашла в комментарии слова «CREATE UNIQUE INDEX IF NOT EXISTS», а дальше вместо имени стояла точка — конец предложения. Захватилось if. Настоящий индекс из следующей строки при этом тоже нашёлся, но факт «в базе нет индекса if» превратил зелёную сверку в красную и увёл разбор в сторону.

Ошибка мелкая. Интереснее то, что выяснилось при починке.

Такая защита в проекте уже была. Два других теста, разбиравших SQL, содержали каждый свою копию функции, срезающей комментарии, — и до остальных пяти разборщиков эта копия просто не доехала. То есть проблема была не «нет механизма», а «механизм есть в двух экземплярах и не имеет общего дома».

Свёл в один модуль:

export function sqlWithoutComments(sql) {
  return sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/--.*$/gm, "");
}

Три строки, и они честно сообщают о своей границе применимости: -- вырезается где угодно, в том числе внутри строкового литерала. Отличить литерал от комментария без настоящего разбора SQL нельзя, а тащить в проект парсер ради семи регулярок — лечение хуже болезни.

Поэтому условие применимости стало частью теста и проверяется по живым файлам:

it("УСЛОВИЕ ПРИМЕНИМОСТИ: в миграциях нет однострочных литералов с двумя дефисами", () => {
  const offenders = [];
  for (const f of readdirSync(MIG_DIR).filter((x) => x.endsWith(".sql"))) {
    readFileSync(join(MIG_DIR, f), "utf8")
      .split("\n")
      .forEach((line, i) => {
        if (/^\s*--/.test(line)) return; // сама строка-комментарий не в счёт
        for (const m of line.matchAll(/'[^'\n]*--[^'\n]*'/g)) {
          offenders.push(`${f}:${i + 1} ${m[0].slice(0, 60)}`);
        }
      });
  }
  expect(offenders, "появился литерал с «--»: очистка его порежет, разбор начнёт врать").toEqual([]);
});

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

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

6. Картинка, которую опознавали по имени папки

Короткий, но показательный.

В игре есть косметические облики, они подменяют портрет героя. Живая проба проверяла, что после надевания облика портрет действительно сменился, и опознавала картинку по пути к файлу — в пути было имя папки первого облика.

Пока облик был один, проба работала. На втором она сказала «портрет не сменился», хотя он сменился: путь просто вёл в другую папку, и условие, зашитое под первый случай, перестало что-либо значить.

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

7. Событие, которое считалось, но не доезжало

И последний случай — тот, что стоил дороже всех остальных, потому что врал не тест, а цифры, по которым принимаются решения.

У игры есть метки источников: ссылка вида ?startapp=src_habr должна записать в базу, откуда пришёл игрок. Код был написан, миграция накатана, колонка в базе есть, тест на разбор параметра зелёный.

В отчёте — ноль меток. При этом игроки в базе были.

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

Причины оказалось две, и обе поучительны.

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

Вторая: тестовые прогоны писались в ту же таблицу, что и живые игроки, и их никто не отделял.

Починка отчёта — отдельная скучная работа. А вот проверку я на этот раз написал сразу как проверку эффекта, и обеими сторонами:

const START_PARAM = "src_probe";
const STORED_CODE = "probe"; // в базе метка хранится БЕЗ приставки

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

Вторая половина здесь не формальность. Проверка «метка записалась» одна была бы зелёной и в мире, где в базу пишется вообще всё подряд, включая мусор из чужих параметров. Пара «попадание + промах» отсекает этот вариант.

И побочная находка, ради которой стоило написать пробу целиком: я ожидал в базе строку src_probe, а там лежало probe. Приставку срезает разбор — она нужна только для того, чтобы отличить нашу метку от прочих параметров запуска. Совершенно правильное поведение кода, о котором я успел забыть. Тест на форму, сверявший «код содержит имя параметра», об этом расхождении сказать не мог ничего.

После этого я завёл замок на само покрытие: у каждой ступени воронки обязана быть живая проба, которая запрашивает базу по имени события. Не упоминает имя в комментарии — а делает запрос:

for (const m of src.matchAll(/event=eq\.([a-z0-9_]+)/g)) found.add(m[1]);

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

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

Чем это ловится системно

Все семь случаев объединяет одно: тест был зелёным, потому что смотрел на форму — на присутствие слова, имени, вызова, — а не на эффект. Форма переживает поломку. Эффект — нет.

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

Числа, чтобы было понятно, во что это обходится. Область — только боевой движок, около 13 400 мутантов. Первый полный прогон занял 4 часа 14 минут. Прогон после крупной стройки — уже 23,5 часа: связей в коде стало больше, и время растёт быстрее объёма. В CI такое не поставишь, это ручной инструмент «раз в цикл».

Читать его результат надо осторожно, иначе он сам станет источником неверных выводов. Первый прогон дал оценку 45%, и это не значит, что половина боя не проверена. Из 3603 выживших мутантов около трёх тысяч — подмена текста: описания способностей и коды предметов не обязаны сверяться тестом посимвольно. Настоящий материал — 606 подмен поведения, из которых интересны три категории: арифметика, равенство, сравнение. Перевёрнутый знак и > вместо >= — вот что стоит смотреть в первую очередь. Их в первом прогоне выжило 220, из них 194 в одном файле — симуляции боя.

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

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

Что в итоге поменялось в привычках

Из этих историй в проекте выросло несколько правил. Они звучат банально — ровно до того момента, когда цена каждого измерена в потерянных днях.

Проверять эффект, а не форму. «Событие дошло» — не «прокрутка произошла». «Имя в файле есть» — не «класс попал в разметку». «Функция вызвана» — не «результат использован».

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

У разбора текста регуляркой должна быть проверка на самого себя. Сломавшийся разбор обязан краснеть, а не молча возвращать пустоту, которая всех устраивает.

Условие, при котором проверка вообще имеет смысл, — часть проверки. Если эмуляция перестала быть мобильной, а срезание комментариев перестало быть безопасным, узнать об этом надо от теста, а не от продакшена.

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

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


Проект, из которого все примеры, — EclipseFall, MMORPG внутри Telegram: серверный расчёт боя, подземелья, арена, эндгейм по образцу больших клиентских MMO. Если интересно посмотреть, что из этого получилось: https://t.me/eclipsefall_bot/app?startapp=src_habr

Готов ответить в комментариях — и про мутационное тестирование на большом TypeScript-проекте, и про то, как устроен серверный бой, и про то, чем ещё живой мини-апп в Telegram отличается от обычного веба.