Я делаю 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 отличается от обычного веба.