Уже больше 10 лет _большая_ часть моей работы — чистый JavaScript. Но иногда приходится заниматься и чуть более типизированными языками. И каждый раз после такого контакта остается странное чувство «непонятности», почему же в js вот так же сделать нельзя.

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

А то говорят тут всякие, что mocking is a coding smell.



«Не» правильные моки


Какие основные доводы можно привести против моков? Если кратко:
Mocking is required when our decomposition strategy has failed.
— как говорил Eric Elliot по ссылке выше.

А если по русски —

  1. У тебя руки кривые
  2. Используй DI!
  3. Моки — они не честные

Есть только одна проблема — 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% что-то хочется от маленькой библиотечки для моков — ты просто знай к кому можно обратиться.

Больше по ссылке.

Комментарии (1)


  1. justboris
    05.12.2017 05:36

    Продолжение серии "упарываемся по мокам на отлично". Спасибо за ваш труд и публикацию!