Уже больше 10 лет _большая_ часть моей работы — чистый JavaScript. Но иногда приходится заниматься и чуть более типизированными языками. И каждый раз после такого контакта остается странное чувство «непонятности», почему же в js вот так же сделать нельзя.
Особенно потому, что можно. И сегодня мы попробуем добавить немного типизации в самую хаотическую область javascript — mock-и в unit-тестах.
А то говорят тут всякие, что mocking is a coding smell.
Какие основные доводы можно привести против моков? Если кратко:
А если по русски —
Есть только одна проблема — nodejs является IoC фреймворком, и модульная система в текущем исполнении является DI. В принципе именно по этому популярные библиотеки для моков — proxyquire, mockery и другие, вообще в принципе работают.
То есть, чисто технически — mocking is not a coding smell.
Но тут есть другая сторона вопроса — сами библиотеки «пахнут» так сильно, что немного заражают весь остальной код. Proxyquire просто кривоват, mockery слишком медленный, моки в jest особо прекрасны. Некий обзор этого я давал в майской статье про rewiremock.
«Правильность» что мока, что теста, очень легко определить — должно правильно падать, и должно падать в правильном месте. Если мок кривой — должен упать мок, а не expect(someFunction).to.be.called(). Ведь иногда можно так долго гадать, почему же метод не вызывается, когда все должно быть хорошо.
В proxyquire была хорошая команда — callThrough, позволяющая использовать «реальный» файл как основу. Вот только ее не рекомендовали использовать, потому что сложно контролировать какие экспорты замокались, а какие нет. На то, что сложно контролировать процесс файла целиком — даже внимание не обратили.
В Jest есть очень удобная система автоматический моков __mock__, позволяющая прозрачно предоставлять моки для реальных файлом… в немного сложно контролируемом виде. Или все или ничего. Ну и никто эти моки не контролирует.
Главную проблему моков очень легко описать одним дичайшим примером из tslint, правилом no-export-by-default:
В случае моков это будет еще веселее — тесты останутся зелеными, так как будут работать с моком, который как работал, так и работает, а вот реальный файл — реальный файл может творить что угодно — для тестов его не существует.
Это и есть главная проблема, и главный coding smell от моков. Ну не добавляют они уверенности в завтрашнем дне.
Я сам с такой ситуацией сталкивался пару раз, и на третий решил просто ее закрыть технически. А если конкретнее — rewiremock v3 поддерживает статическую типизацию моков для Flow/JS и shallow проверку экспортов для обычного JS.
Итак — давайте начнем с примера.
Proxyquire делает именно что что сказано — перегружает зависимости. Но не проверяет что же он там на самом деле делает — что fs будет запрошен, что функция перехвачена, что операция валидна.
У mockery все еще хуже, так как обьявление моков и их использование немного разнесено в пространстве.
В rewiremock — совсем не лучше. Учитывая что базовый API примерно повторяет интерфейс mockery.
Это обычный способ создать мок. Ему по барабану есть ли default export у файла, и есть ли экспорт с именем helper, который к тому же является числом. Как описывалось выше — расхождение мока с реальным файлом, который он должен заменить, и есть проблема, и есть основной источник «запаха».
Решений у данной ситуации два:
Первое использует динамические импорты, которые для TS/Flow возвращают тизированные результаты промисы, информацию из которых можно использовать в дальнейшем исключительно посредством статической типизации. От нижележащего кода требуется только поддерживать возможность задавать имя мока фунцией импорта. Привет асинхронность :(
Более того — вся нужная информация доступна для IntelliSence или другого автокомплитера. Чистый TypeScript в действии (ну или Flow). Это не только делает моки «лучше», но и улучшает «пользовательсткий опыт» при написании моков. IDE поможет и подскажет.
Второй вариант попроще, но может работать только в рантайме, в том числе его без проблем можно портировать почти в любые другие библиотеки, так как кода там — буквально три строчки.
В данном случае перед операцией замены реального файла моком (где-то глубоко в require) будет на самом деле загружен реальный файл, и его экспорты будут сравнены с данным моком на совпадение имен типов.
Главный плюс этого варианта — работает синхронно — в итоге работать с этим удобно, в то время как использование динамических импортов требует использования асинхронных API, что не очень удобно :(
PS: с «типизацией» старого доброго синхронного require есть проблемы, так что не стоит даже пытаться.
Буду честен — единственное что улучшает типизация моков — время написания моков(если есть типизация) и нахождения багов (в обоих случаях). Мне просто было теоритичеки интересно можно ли это сделать в принципе, и я это в принципе и сделал.
Jest насколько хорош, что у него есть даже своя собственная система моков, запаянная в его песочницу исполнения файлов (которая — одна из главных фичей).
Система эта двуликая, и состоит из автомоков __mocks__, и manual моков. Первые — прекрасны, вторые — ужасны. Ну а в кишках Jest все плохо и не совсем логично — он подменяет «собой» и модульную систему node.js, и babel и все остальное, заполняя все пространство и уничтожая всю «стандартную» инфраструктуру. Для многих это факт — proxyquire с Jest не работает. Ничего с Jest не работает.
Но иметь типизированные моки хотелось бы, и решение есть.
Это маленькая cli утилитка jest-typed-mock, которая сканирует директории в поисках моков, и проверяет на соотвествие реальным файлам.
Режима работы 4:
Я запустил ее на паре примеров, и смог найти различные «накопившиеся» расхождения в моках и релиазциях, которые были на пути к «тесты зеленые, а прод нет», но (слава богу) пока не дошли. Честное слово — хотелось бы видеть такую систему встроенную в Jest по умолчанию.
Для справки: rewiremock достаточно наглый, чтобы подвинуть Jest и мокать самостоятельно. Для этого нужно совершить всего два действия —
Это полностью убиваем jest для тех файлов, которые будут запрошены — никакого sandboxing, jest как локальной переменной, конечно же никаких jest моков, тем более __mocks__… Но заказ на такое поступал.
Вот уже почти год я пытаюсь пилить rewiremock. Он начался как замена proxyquire, продолжился как замена mockery, и потопал куда-то дальше.
Каждый раз, когда я пью кофе в тени эвкалипта, я думаю — какую бы новую фичу добавить, и зачем. Потом понимаю что это не возможно… и добиваюсь своего через пару месяцев.
Так было с режимами изоляции, с работой под webpack, jest, ну а теперь с типизацией. Ну а если тебе %username% что-то хочется от маленькой библиотечки для моков — ты просто знай к кому можно обратиться.
Больше по ссылке.
Особенно потому, что можно. И сегодня мы попробуем добавить немного типизации в самую хаотическую область javascript — mock-и в unit-тестах.
А то говорят тут всякие, что mocking is a coding smell.
«Не» правильные моки
Какие основные доводы можно привести против моков? Если кратко:
Mocking is required when our decomposition strategy has failed.
— как говорил Eric Elliot по ссылке выше.
А если по русски —
- У тебя руки кривые
- Используй DI!
- Моки — они не честные
Есть только одна проблема — nodejs является IoC фреймворком, и модульная система в текущем исполнении является DI. В принципе именно по этому популярные библиотеки для моков — proxyquire, mockery и другие, вообще в принципе работают.
То есть, чисто технически — mocking is not a coding smell.
Но тут есть другая сторона вопроса — сами библиотеки «пахнут» так сильно, что немного заражают весь остальной код. Proxyquire просто кривоват, mockery слишком медленный, моки в jest особо прекрасны. Некий обзор этого я давал в майской статье про rewiremock.
«Правильные моки»
«Правильность» что мока, что теста, очень легко определить — должно правильно падать, и должно падать в правильном месте. Если мок кривой — должен упать мок, а не expect(someFunction).to.be.called(). Ведь иногда можно так долго гадать, почему же метод не вызывается, когда все должно быть хорошо.
В proxyquire была хорошая команда — callThrough, позволяющая использовать «реальный» файл как основу. Вот только ее не рекомендовали использовать, потому что сложно контролировать какие экспорты замокались, а какие нет. На то, что сложно контролировать процесс файла целиком — даже внимание не обратили.
В Jest есть очень удобная система автоматический моков __mock__, позволяющая прозрачно предоставлять моки для реальных файлом… в немного сложно контролируемом виде. Или все или ничего. Ну и никто эти моки не контролирует.
Главную проблему моков очень легко описать одним дичайшим примером из tslint, правилом no-export-by-default:
// app.ts
import ActionPopover from "./action";
// action.ts
export default class ActionMenu {
...
}
// oops, we just renamed this from popover -> menu but our consumer had no idea!
В случае моков это будет еще веселее — тесты останутся зелеными, так как будут работать с моком, который как работал, так и работает, а вот реальный файл — реальный файл может творить что угодно — для тестов его не существует.
Это и есть главная проблема, и главный coding smell от моков. Ну не добавляют они уверенности в завтрашнем дне.
Type safety
Я сам с такой ситуацией сталкивался пару раз, и на третий решил просто ее закрыть технически. А если конкретнее — rewiremock v3 поддерживает статическую типизацию моков для Flow/JS и shallow проверку экспортов для обычного JS.
TypeScript vs Flow?
Мне потребовалась примерно неделя, чтобы написать по 10 строчек TS/Flow которые бы сделали всю работу, при этом 90% времени потратил на Flow, и еще неделя чтобы написать еще 10 строчек, при этом 90% времени потратил на TS. Победила дружба.
Итак — давайте начнем с примера.
var foo = proxyquire('./foo', {
'fs': { fileWroteSync }
});
Proxyquire делает именно что что сказано — перегружает зависимости. Но не проверяет что же он там на самом деле делает — что fs будет запрошен, что функция перехвачена, что операция валидна.
У mockery все еще хуже, так как обьявление моков и их использование немного разнесено в пространстве.
mockery.registerMock('fs', fsMock);
mockery.enable();
const mocked = require(...)
В rewiremock — совсем не лучше. Учитывая что базовый API примерно повторяет интерфейс mockery.
// Старый API - требует имя файла строчкой. Пиши/мокай что угодно
rewiremock('./b.js').withDefault(overrideDefault).with({ helper:2});
Это обычный способ создать мок. Ему по барабану есть ли default export у файла, и есть ли экспорт с именем helper, который к тому же является числом. Как описывалось выше — расхождение мока с реальным файлом, который он должен заменить, и есть проблема, и есть основной источник «запаха».
Решений у данной ситуации два:
Первое использует динамические импорты, которые для TS/Flow возвращают тизированные результаты промисы, информацию из которых можно использовать в дальнейшем исключительно посредством статической типизации. От нижележащего кода требуется только поддерживать возможность задавать имя мока фунцией импорта. Привет асинхронность :(
// Новый API - использует import, что решает много проблем с name-resolution
rewiremock(() => import('./b.js'))
.withDefault(overrideDefault) // метода default может просто не быть
.with({ helper: 42 }) // c этой проверкой все тоже хоршо
Более того — вся нужная информация доступна для IntelliSence или другого автокомплитера. Чистый TypeScript в действии (ну или Flow). Это не только делает моки «лучше», но и улучшает «пользовательсткий опыт» при написании моков. IDE поможет и подскажет.
Второй вариант попроще, но может работать только в рантайме, в том числе его без проблем можно портировать почти в любые другие библиотеки, так как кода там — буквально три строчки.
rewiremock('./b.js')
.with({ helper: 42 })
.toMatchOrigin(); // дополнительная опция
В данном случае перед операцией замены реального файла моком (где-то глубоко в require) будет на самом деле загружен реальный файл, и его экспорты будут сравнены с данным моком на совпадение имен типов.
Главный плюс этого варианта — работает синхронно — в итоге работать с этим удобно, в то время как использование динамических импортов требует использования асинхронных API, что не очень удобно :(
PS: с «типизацией» старого доброго синхронного require есть проблемы, так что не стоит даже пытаться.
Буду честен — единственное что улучшает типизация моков — время написания моков(если есть типизация) и нахождения багов (в обоих случаях). Мне просто было теоритичеки интересно можно ли это сделать в принципе, и я это в принципе и сделал.
Jest?
Jest насколько хорош, что у него есть даже своя собственная система моков, запаянная в его песочницу исполнения файлов (которая — одна из главных фичей).
Система эта двуликая, и состоит из автомоков __mocks__, и manual моков. Первые — прекрасны, вторые — ужасны. Ну а в кишках Jest все плохо и не совсем логично — он подменяет «собой» и модульную систему node.js, и babel и все остальное, заполняя все пространство и уничтожая всю «стандартную» инфраструктуру. Для многих это факт — proxyquire с Jest не работает. Ничего с Jest не работает.
Но иметь типизированные моки хотелось бы, и решение есть.
Это маленькая cli утилитка jest-typed-mock, которая сканирует директории в поисках моков, и проверяет на соотвествие реальным файлам.
Режима работы 4:
- jest-typed-mock flow, для Flow
- jest-typed-mock typescript, для TS
- jest-typed-mock javascript, для «строгой» проверки JS (сравнивает колличество аргументов в функциях)
- jest-typed-mock exports, для проверки только имен и типов.
Я запустил ее на паре примеров, и смог найти различные «накопившиеся» расхождения в моках и релиазциях, которые были на пути к «тесты зеленые, а прод нет», но (слава богу) пока не дошли. Честное слово — хотелось бы видеть такую систему встроенную в Jest по умолчанию.
Для справки: rewiremock достаточно наглый, чтобы подвинуть Jest и мокать самостоятельно. Для этого нужно совершить всего два действия —
// 1. Восстановить связь между модулями.
// Иначе "родителем" rewiremock будет jest-runtime
rewiremock.overrideEntryPoint(module);
// 2. Переписать require на "нормальный"
require = rewiremock.requireActual;
Это полностью убиваем jest для тех файлов, которые будут запрошены — никакого sandboxing, jest как локальной переменной, конечно же никаких jest моков, тем более __mocks__… Но заказ на такое поступал.
Заключение
Вот уже почти год я пытаюсь пилить rewiremock. Он начался как замена proxyquire, продолжился как замена mockery, и потопал куда-то дальше.
Каждый раз, когда я пью кофе в тени эвкалипта, я думаю — какую бы новую фичу добавить, и зачем. Потом понимаю что это не возможно… и добиваюсь своего через пару месяцев.
Так было с режимами изоляции, с работой под webpack, jest, ну а теперь с типизацией. Ну а если тебе %username% что-то хочется от маленькой библиотечки для моков — ты просто знай к кому можно обратиться.
Больше по ссылке.
justboris
Продолжение серии "упарываемся по мокам на отлично". Спасибо за ваш труд и публикацию!