Инженерная практика
Тестовые дублёры: London против Detroit и ловушка чрезмерного мокинга
Команда мокает всё. Каждый коллаборатор в каждом юнит-тесте — это мок с expect(...).toHaveBeenCalledWith(...). Их набор — это 2400 тестов, все зелёные, 91% покрытия. Затем рефакторинг, разбивающий один сервис на два — чисто внутренняя реорганизация, поведение идентично, — за один день красит 600 тестов в красный. Ни один из них не поймал баг; они поймали лишь факт, что внутренние вызовы переместились. Хуже того, месяцем ранее реальный баг в ценообразовании уехал в продакшен с полностью зелёным набором, потому что моки возвращали заготовленные значения и никто не проверял реально вычисленную сумму. Всё это время набор измерял не то.
Пять видов дублёра и тот, что кусается
«Мок» используют как универсальный ярлык, но xUnit Test Patterns называет пять различных тестовых дублёров, и это различие решает, будет ли тест надёжным или хрупким. Dummy (пустышка) — это заглушка, которую передают, но никогда не используют. Stub (стаб) возвращает заготовленные ответы на вызовы — он подаёт состояние внутрь. Fake (фейк) — это рабочая облегчённая реализация (репозиторий в памяти вместо Postgres). Spy (шпион) записывает, как его вызывали, чтобы вы могли проинспектировать это после. Mock (мок) запрограммирован ожиданиями: он утверждает, что конкретные вызовы случились, конкретным образом, и валит тест, если их не было.
Первые четыре подают входы или наблюдают выходы; мок — единственный, кто утверждает взаимодействия, и именно отсюда берётся хрупкость. Мок вшивает структуру вызовов продакшен-кода в условие прохождения/падения теста. В тот миг, когда вы меняете, как код достигает результата — даже при идентичном наблюдаемом поведении, — ожидания мока больше не совпадают, и тест падает. Стабы и фейки так не делают; они просто подают данные и дают вам проверить конечное состояние. Хвататься за мок там, где хватило бы стаба, — это самый частый способ, которым команды производят хрупкие тесты.
London (mockist) против Detroit (classicist)
Эти две школы — реальное, поимённо названное разногласие. Школа London / mockist работает снаружи-внутрь: юнит — это один класс, вы мокаете всех его коллабораторов и тестируете, утверждая взаимодействия между объектами — что нужные сообщения были посланы в нужном порядке. Школа Detroit / classicist (также Chicago) работает изнутри-наружу: юнит — это поведение, которое может охватывать несколько настоящих объектов, вы используете настоящих коллабораторов везде, где можете, мокаете лишь то, что обязаны, и утверждаете конечное состояние, а не вызовы.
Практическое следствие — это то, что переживает рефакторинг. Classicist-тесты утверждают результаты, поэтому они позволяют беспощадный рефакторинг — вы можете свободно реструктурировать внутренности, и тест остаётся зелёным, пока результат верен. Mockist-тесты утверждают структуру вызовов, поэтому они фиксируют реализацию: они точно ловят замысел дизайна, но ломаются всякий раз, когда меняется внутреннее взаимодействие. Сила London — быстрая обратная связь по дизайну снаружи-внутрь и крошечные изолированные юниты; её режим отказа — это ровно тот день с 600 красными тестами: набор, настолько привязанный к структуре, что рефакторинг становится непомерно дорогим.
| Аспект | London / mockist | Detroit / classicist |
|---|---|---|
| Юнит = | Один класс | Поведение через настоящих коллабораторов |
| Коллабораторы | Замоканы | Настоящие везде, где возможно |
| Проверяет | Взаимодействия (какие вызовы случились) | Конечное состояние / результат |
| Переживает рефакторинг? | Часто ломается — привязан к структуре вызовов | Да — ломается только при смене поведения |
| Режим отказа | Хрупкий набор, дорогие рефакторинги | Труднее локализовать падение |
Правило границ разрешает спор
Синтез сеньора — это не «выберите школу». Это правило границ: мокайте то, что не можете запустить, используйте настоящие объекты для того, что можете. Мокайте вещи медленные, недетерминированные или с побочными эффектами, которые нельзя откатить — сеть, часы, платёжный процессор, отправитель писем, сторонний API. Для коллабораторов, которыми вы владеете и которые работают быстро и чисто, используйте настоящий объект или фейк; проверка конечного состояния поверх настоящих внутренних объектов даёт вам тест, который ломается только тогда, когда ломается поведение. Это в основном позиция classicist с дисциплиной London, применённой на краях системы, и это растворяет ловушку чрезмерного мокинга: вы никогда не мокаете объект-значение или чистый хелпер, потому что не от чего изолировать.
Решающий вопрос для любого дублёра: если я рефакторю внутренности, не меняя поведения, должен ли этот тест упасть? Если ответ «нет», вы используете мок там, где место стабу или настоящему объекту. Чрезмерный мокинг — это когда ответ «да» для тестов кода, которым вы управляете, — и это ровно то, что превращает зелёный набор в 600 красных тестов, не поймавших ни одного бага, пока реальная ошибка в ценообразовании просочилась, потому что моки возвращали заготовленные числа, которые никто никогда не сверял с реальным вычислением.
Почему это работает
Чрезмерный мокинг ощущается продуктивным, потому что делает каждый тест быстрым, изолированным и тривиально детерминированным — вы управляете каждым входом. Но этот контроль и есть ловушка: тест, где вы застабили каждого коллаборатора, в пределе — это тест того, что код вызывает методы, которые вы велели ему вызвать. Он не может поймать интеграционный баг между двумя настоящими объектами и не может поймать неверный результат, если вы застабили результат. Моки превращают тест в зеркало реализации, поэтому он отражает каждое структурное изменение как падение и не отражает ничего из поведенческой правды. Настоящие коллабораторы медленнее и труднее в настройке, но они — единственный способ, которым тест может разойтись с кодом.
Вы тестируете PriceCalculator, который использует объект TaxRule (чистый, ваш собственный) и CurrencyApi (сторонний HTTP). Как продублировать каждый?
Чем мок отличается от стаба и почему это важно для хрупкости?
Чисто рефакторинговое разбиение одного сервиса на два красит 600 мок-нагруженных тестов в красный, ни один не ловит баг. В чём корневая причина?
Упорядочьте, как деградирует зелёный, но бесполезный чрезмерно замоканный набор:
- 1 Каждый коллаборатор замокан, включая чистые объекты, которыми вы владеете
- 2 Тесты проверяют, какие вызовы случились, а не итоговый результат
- 3 Застабленные возвращаемые значения означают, что реальное вычисление никогда не проверяется
- 4 Реальный баг уезжает в прод с полностью зелёным набором
- 5 Сохраняющий поведение рефакторинг красит сотни тестов в красный без всякого бага
- 01Объясните London против Detroit в TDD и как правило границ разрешает это разногласие.
- 02Как чрезмерно замоканный набор может быть зелёным на 91% и всё же пропустить реальный баг, ломаясь на ничего не меняющем рефакторинге?
«Мок» — это универсальный ярлык, но пять тестовых дублёров различаются способами, которые решают надёжность: dummy, стабы, фейки и шпионы подают входы или наблюдают выходы, тогда как мок утверждает, что конкретные взаимодействия случились — и эта проверка вызовов и есть источник хрупкости. Школа London (mockist) трактует юнит как один класс, мокает каждого коллаборатора и проверяет взаимодействия; школа Detroit/Chicago (classicist) трактует юнит как поведение через настоящие объекты, мокает лишь то, что обязана, и проверяет конечное состояние. Classicist-тесты переживают рефакторинги, потому что проверяют результаты; mockist-тесты фиксируют реализацию и ломаются при смене взаимодействия, и так сохраняющий поведение рефакторинг красит 600 тестов в красный без единого бага среди них, пока реальная ошибка ценообразования уезжает зелёной за заготовленными значениями стабов. Решение сеньора — правило границ: мокайте то, что не можете запустить — сеть, часы, оплату, почту, сторонние API, — и используйте настоящие объекты или фейки для быстрого, чистого кода, которым владеете, проверяя конечное состояние. Решающий вопрос для любого дублёра — должен ли сохраняющий поведение рефакторинг уронить этот тест; если не должен, вы схватились за мок там, где место стабу или настоящему объекту.