Модульный JavaScript находится на интересном перекрестке между модулями CommonJS и ES. Хотя синтаксис модуля ES является обычным явлением, он часто соответствует CommonJS, что может вызвать некоторые проблемы модульного тестирования. Кроме того, при использовании собственных модулей ES есть некоторые особенности, о которых следует помнить при написании тестов. В этой статье исследуются конкретные проблемы с заглушками и шпионами в обоих этих шаблонах и предлагается ряд решений.

Первая проблема заключается в том, что при использовании синтаксиса ES6 export для совместного использования отдельных функций, но при компиляции в CommonJS невозможно заглушить или шпионить за функцией, которая вызывается другим в том же модуль. Во-вторых, при использовании собственных модулей ES вы не можете заглушить или отслеживать именованный импорт из любого модуля.

Заглушка или слежка за экспортом одного и того же модуля

Рассмотрим этот код:

export function bar(str) {
  return str;
}

export function foo() {
  bar('test');
}

Когда мое приложение запускается, все вроде нормально. Однако когда я пишу тест для foo и создаю шпион для bar, он не может записать какие-либо данные для bar:

expected spy to have been called at least once, but it was never called

При поиске решений этой проблемы я столкнулся с большим количеством запутанной информации, поэтому позвольте мне прояснить несколько моментов.

Эта проблема относится к любому фреймворку имитации JavaScript, а не только к Jest или Sinon.

Давайте начнем с развенчания самого большого мифа: ни в коем случае не виноват фреймворк тестирования. Среда тестирования просто не может прочитать правильный объект.

Это не проблема с Babel

Я также видел, как утверждается, что проблема связана с тем, как Babel компилирует JavaScript. По умолчанию он назначает ваши экспортируемые функции объекту exports. В следующем разделе будет объяснено, почему это влияет на одни и те же заглушки модулей и шпионов.

Стоит отметить, что Babel поддерживает модули ES, которые вы можете указать в своих целях как описано здесь. Если вы это сделаете, вам нужно будет убедиться, что все ваши целевые среды поддерживают модули ES и решить проблему, которую я объясняю в разделе Заглушки и шпионы с модулями ES ниже.

Это проблема при записи одним шаблоном, а затем при переносе на другой.

При запуске вашего набора модульных тестов через Jest или Mocha они будут использовать среду Node. Node по умолчанию использует синтаксис CommonJS и не понимает синтаксис импорта / экспорта, поэтому при запуске программы вы, вероятно, получите ошибку типа Unexpected token ‘export’. Здесь на помощь приходят такие инструменты, как Babel - они компилируют ваши модули ES6 в CommonJS, чтобы их можно было понять в ваших целях.

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

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.bar = bar;
exports.foo = foo;
function bar(str) {
  return str;
}
function foo() {
  bar('test');
}

Когда вы пытаетесь заглушить / шпионить за bar, он будет использовать ссылку exports.bar, которая не является той же ссылкой, что и в foo. Процитирую одного из участников Sinon, где эта проблема была поднята: Заглушив экспорт бара, вы перезаписываете экспортированный символ, но не внутреннюю ссылку на функцию, которая связана закрытием в foo.

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

Заглушки и шпионы с ES модулями

Теперь, когда мы рассмотрели общую проблему с ES6 ›CommonJS, давайте взглянем на собственные модули ES. Здесь нам не нужен инструмент для преобразования наших модулей из одного шаблона в другой, нам просто нужно сообщить нашей среде, какие типы модулей мы используем. Проблема, с которой вы можете столкнуться при запуске своих наборов тестов, заключается в том, что невозможно использовать заглушки или шпионы для именованного импорта из любого модуля. Если вы попробуете, то получите такую ​​ошибку:

ES Modules cannot be stubbed

С собственными модулями ES вы не можете заглушить или шпионить за экспортируемой функцией, доступной с помощью простого именованного подхода export / import, потому что импорт модуля ES6 - это представления экспортируемых сущностей только для чтения. На самом деле это то, что делает их такими замечательными, потому что соединения с переменными, объявленными внутри тела модуля, остаются активными.

Что делают под капотом заглушки и шпионы?

Прежде чем искать какие-либо решения, давайте разберемся, что делают шпионы под капотом, чтобы лучше понять первопричину. Шпионы используют предметы и методы. Шпион временно сохраняет ссылку на исходную функцию, но перезаписывает значение следующим образом:

obj[method] = function() {
//spy logic here
}

Это означает, что когда функция-шпион или заглушка пытается переопределить оригинал во время выполнения, она потерпит неудачу. Если мы поместим try / catch вокруг приведенного выше оператора, мы получим эту ошибку:

Cannot assign to read only property 'foo' of object '[object Module]

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

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

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

1) Сохраняйте и экспортируйте функции как объект

По сути, это решение, связанное с пространством имен. Идея состоит в том, чтобы экспортировать объект, который ссылается на все общедоступные функции вашего модуля, а затем ссылаться на этот объект в модуле.

export function bar(str) {
  return str;
}

export function foo() {
  utils.bar('some text');
}

export const utils = {
    foo,
    bar,
};

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

Обратите внимание, вам не нужно экспортировать этот объект в качестве экспорта по умолчанию, как иногда предполагается. Не имеет значения, является ли объект экспортом по умолчанию; важно только то, что объект экспортируется.

Плюсы

  • Требуются минимальные изменения в тестах, если вы уже используете псевдоним import all *.
  • Нет необходимости в дополнительных зависимостях

Минусы

  • Требуются изменения в коде приложения
  • Включает потенциально ненужное пространство имен

2) Поместите свои функции в класс

Подобно подходу, описанному выше, это позволит вам ссылаться на свои функции как на методы.

export class MyModule {
  bar() {
    return 'bar';
  }

  foo() {
    this.bar();
  }
}

Плюсы

  • Если вы уже пользуетесь курсами, у вас не так много работы
  • Нет необходимости в дополнительных зависимостях

Минусы

  • Введение классов в существующий код было бы значительным рефакторингом

3) Внедрение зависимости

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

export function bar () {
    return 'bar';
}

export function foo (_bar = bar) {
    _bar();
}

Плюсы

  • Экспортное пространство имен не используется
  • Нет новых зависимостей для настройки или обслуживания

Минусы

  • Потенциально ненужное изменение функции remit

4) Используйте сторонний инструмент

Доступны инструменты, которые могут перехватить, как модули ссылаются на экспортированные данные. Два, о которых я знаю, это Rewire и ProxyRequire (это только CommonJS). Как следует из названия, Rewire позволяет нам полностью переопределять ссылки на данные.

Один из подходов к Rewire описан здесь в их документации, но вот более краткий фрагмент, который поможет вам на вашем пути:

import __RewireAPI__, * as module from '../module';

describe('foo', () => {
  it('calls bar', () => {
    const barMock = jest.fn();
    __RewireAPI__.__Rewire__('bar', barMock);
    
    module.foo();

    expect(bar).toHaveBeenCalledTimes(1);
  });
});

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

Плюсы

  • Никаких изменений в коде приложения не требуется

Минусы

  • Требуются изменения в существующий тестовый код
  • Новые зависимости для поддержки
  • Новый API для изучения
  • Может нарушить существующие тесты

Другие решения

Есть два других решения, о которых стоит упомянуть вкратце. Во-первых, вы можете импортировать модуль в себя (как описано здесь MostafaR), а затем ссылаться на свои экспортированные функции, как если бы они были внешними зависимостями.

import * as thisModule from './module';
export function bar () {
    return 'bar';
}
export function foo () {
    thisModule.bar();
}

Хотя это довольно изящное решение, оно не работает с модулями ES и вводит циклические зависимости, которые могут затруднить анализ кода.

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

Заключение

Помните, какое бы решение вы ни выбрали, ваша библиотека тестирования, скорее всего, потребует, чтобы заглушки и шпионы ссылались на метод, поэтому обязательно импортируйте свой модуль, используя псевдоним all:

import * as myModule from './myModule';
const mySpy = sinon.spy(myModule, 'myFn');

Также будьте осторожны, какую версию Node вы используете. У меня были проблемы со старыми версиями.

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

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

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

Комментарии, вопросы и предложения приветствуются.

Больше контента на plainenglish.io