Как я могу издеваться над импортом модуля ES6?

147

У меня есть следующие модули ES6:

Файл network.js

export function getDataFromServer() {
  return ...
}

Файл widget.js

import { getDataFromServer } from 'network.js';

export class Widget() {
  constructor() {
    getDataFromServer("dataForWidget")
    .then(data => this.render(data));
  }

  render() {
    ...
  }
}

Я ищу способ протестировать виджет с помощью фиктивного экземпляра getDataFromServer. Если бы я использовал отдельные <script>s вместо модулей ES6, как в Karma, я мог бы написать свой тест, например:

describe("widget", function() {
  it("should do stuff", function() {
    let getDataFromServer = spyOn(window, "getDataFromServer").andReturn("mockData")
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  });
});

Однако, если я тестирую модули ES6 индивидуально вне браузера (например, с Mocha + Babel ), я бы написал что-то вроде:

import { Widget } from 'widget.js';

describe("widget", function() {
  it("should do stuff", function() {
    let getDataFromServer = spyOn(?????) // How to mock?
    .andReturn("mockData")
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  });
});

Хорошо, но сейчас getDataFromServerон недоступен в window(ну, нет windowвообще), и я не знаю, как вводить что-то прямо в widget.jsобласть видимости.

Так что же мне делать дальше?

  1. Есть ли способ получить доступ к области видимости widget.jsили, по крайней мере, заменить ее импорт моим собственным кодом?
  2. Если нет, как я могу сделать Widgetтестируемым?

Я рассмотрел:

а. Внедрение зависимостей вручную.

Удалите весь импорт из widget.jsи ожидайте, что вызывающий предоставит deps.

export class Widget() {
  constructor(deps) {
    deps.getDataFromServer("dataForWidget")
    .then(data => this.render(data));
  }
}

Мне очень неудобно испортить публичный интерфейс Widget, как этот, и раскрыть детали реализации. Нет.


б. Выставьте импорт, чтобы можно было издеваться над ним.

Что-то типа:

import { getDataFromServer } from 'network.js';

export let deps = {
  getDataFromServer
};

export class Widget() {
  constructor() {
    deps.getDataFromServer("dataForWidget")
    .then(data => this.render(data));
  }
}

тогда:

import { Widget, deps } from 'widget.js';

describe("widget", function() {
  it("should do stuff", function() {
    let getDataFromServer = spyOn(deps.getDataFromServer)  // !
      .andReturn("mockData");
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  });
});

Это менее инвазивно, но требует от меня написания большого количества шаблонов для каждого модуля, и все же существует риск того, что я буду использовать getDataFromServerвместо него deps.getDataFromServerвсе время. Меня это беспокоит, но пока это моя лучшая идея.

Кос
источник
Если нет встроенной поддержки макетов для такого импорта, я, вероятно, подумал бы над написанием собственного преобразователя для babel, конвертирующего ваш импорт стиля ES6 в настраиваемую имитационную систему импорта. Это наверняка добавит еще один уровень возможного сбоя и изменит код, который вы хотите протестировать, ....
t.niese 06
Я не могу установить набор тестов прямо сейчас, но я бы попытался использовать функцию jasmin createSpy( github.com/jasmine/jasmine/blob/… ) с импортированной ссылкой на getDataFromServer из модуля 'network.js'. Итак, в файл тестов виджета вы должны импортировать getDataFromServer, а затемlet spy = createSpy('getDataFromServer', getDataFromServer)
Microfed
Второй вариант - вернуть объект из модуля network.js, а не функцию. Таким образом, вы можете использовать spyOnэтот объект, импортированный из network.jsмодуля. Это всегда ссылка на один и тот же объект.
Microfed
На самом деле, это уже объект, насколько
Microfed
2
Я действительно не понимаю, как внедрение зависимостей портит Widgetпубличный интерфейс? Widgetнапортачил без deps . Почему бы не сделать зависимость явной?
thebearingedge

Ответы:

132

Я начал использовать этот import * as objстиль в своих тестах, который импортирует весь экспорт из модуля как свойства объекта, который затем можно смоделировать. Я считаю, что это намного чище, чем использование чего-то вроде rewire, proxyquire или любого подобного метода. Я делал это чаще всего, например, когда нужно было имитировать действия Redux. Вот что я мог бы использовать для вашего примера выше:

import * as network from 'network.js';

describe("widget", function() {
  it("should do stuff", function() {
    let getDataFromServer = spyOn(network, "getDataFromServer").andReturn("mockData")
    let widget = new Widget();
    expect(getDataFromServer).toHaveBeenCalledWith("dataForWidget");
    expect(otherStuff).toHaveHappened();
  });
});

Если ваша функция случается экспорта по умолчанию, то import * as network from './network'будет производить , {default: getDataFromServer}и вы можете издеваться network.default.

Carpeliam
источник
3
Вы используете import * as objтолько в тесте или также в своем обычном коде?
Чау Тай
40
@carpeliam Это не будет работать со спецификацией модуля ES6, где импорт доступен только для чтения.
ashish
7
Жасмин жалуется, [method_name] is not declared writable or has no setterчто имеет смысл, поскольку импорт es6 постоянен. Есть ли способ обхода?
lpan
2
@Francisc import(в отличие от того require, который может идти куда угодно) поднимается, поэтому технически вы не можете импортировать несколько раз. Похоже, вашего шпиона зовут в другое место? Чтобы не дать тестам ошибиться в состоянии (известном как тестовое загрязнение), вы можете сбросить шпионов в afterEach (например, sinon.sandbox). Я считаю, что Жасмин делает это автоматически.
Carpeliam
11
@ agent47 Проблема в том, что, хотя спецификация ES6 специально препятствует работе этого ответа, именно так, как вы упомянули, большинство людей, которые пишут importна своем JS, на самом деле не используют модули ES6. Что-то вроде webpack или babel вмешается во время сборки и преобразует его либо в свой собственный внутренний механизм для вызова удаленных частей кода (например __webpack_require__), либо в один из стандартов де-факто до ES6 , CommonJS, AMD или UMD. И это преобразование часто не соответствует спецификации. Так что для многих, многих разработчиков сейчас этот ответ работает нормально. На данный момент.
daemonexmachina
36

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

Неправильный пример:

// File mymodule.js

export function myfunc2() {return 2;}
export function myfunc1() {return myfunc2();}

// File tests.js
import * as mymodule

describe('tests', () => {
    beforeEach(() => {
        spyOn(mymodule, 'myfunc2').and.returnValue = 3;
    });

    it('calls myfunc2', () => {
        let out = mymodule.myfunc1();
        // 'out' will still be 2
    });
});

Правильный пример:

export function myfunc2() {return 2;}
export function myfunc1() {return exports.myfunc2();}

// File tests.js
import * as mymodule

describe('tests', () => {
    beforeEach(() => {
        spyOn(mymodule, 'myfunc2').and.returnValue = 3;
    });

    it('calls myfunc2', () => {
        let out = mymodule.myfunc1();
        // 'out' will be 3, which is what you expect
    });
});
вдлоо
источник
5
Хотел бы я проголосовать за этот ответ еще 20 раз! Спасибо!
sfletche
Может кто-нибудь объяснить, почему это так? Является ли exports.myfunc2 () копией myfunc2 () без прямой ссылки?
Колин Уитмарш
2
@ColinWhitmarsh exports.myfunc2- это прямая ссылка, myfunc2пока не spyOnзаменяет ее ссылкой на шпионскую функцию. spyOnизменит значение exports.myfunc2и заменит его на объект-шпион, в то время как myfunc2остается нетронутым в области видимости модуля (потому что spyOnне имеет к нему доступа)
madprog
не следует ли импортировать с *замораживанием объекта, а атрибуты объекта нельзя изменить?
agent47 08
1
Просто обратите внимание, что эта рекомендация по использованию export functionвместе с exports.myfunc2технически смешивает синтаксис модулей commonjs и ES6, и это недопустимо в более новых версиях webpack (2+), которые требуют использования синтаксиса модуля ES6 по принципу «все или ничего». Я добавил ниже ответ, основанный на этом, который будет работать в строгих средах ES6.
QuarkleMotion
7

Ответ vdloo направил меня в правильном направлении, но использование ключевых слов «экспорт» CommonJS и модуля ES6 «экспорт» в одном файле у меня не сработало ( Webpack v2 или более поздняя версия жалуется).

Вместо этого я использую экспорт по умолчанию (именованная переменная), обертывающий все отдельные экспортные модули именованных модулей, а затем импортирую экспорт по умолчанию в свой файл тестов. Я использую следующую настройку экспорта с Mocha / Sinon, и заглушка отлично работает без необходимости перепрограммирования и т. Д .:

// MyModule.js
let MyModule;

export function myfunc2() { return 2; }
export function myfunc1() { return MyModule.myfunc2(); }

export default MyModule = {
  myfunc1,
  myfunc2
}

// tests.js
import MyModule from './MyModule'

describe('MyModule', () => {
  const sandbox = sinon.sandbox.create();
  beforeEach(() => {
    sandbox.stub(MyModule, 'myfunc2').returns(4);
  });
  afterEach(() => {
    sandbox.restore();
  });
  it('myfunc1 is a proxy for myfunc2', () => {
    expect(MyModule.myfunc1()).to.eql(4);
  });
});
QuarkleMotion
источник
Полезный ответ, спасибо. Просто хотел упомянуть, что let MyModuleне требуется использовать экспорт по умолчанию (это может быть необработанный объект). Кроме того, этот метод не требует myfunc1()вызова myfunc2(), он работает, чтобы просто шпионить за ним напрямую.
Марк Эдингтон
@QuarkleMotion: Похоже, вы случайно отредактировали это не в своей основной учетной записи. Вот почему ваше редактирование должно было пройти утверждение вручную - не похоже, что это было от вас, я предполагаю, что это был просто несчастный случай, но, если это было намеренно, вам следует прочитать официальную политику для учетных записей марионеток sock, чтобы вы случайно не нарушайте правила .
Conspicuous Compiler
1
@ConspicuousCompiler благодарит за внимание - это была ошибка, я не собирался изменять этот ответ с помощью моей рабочей учетной записи SO, связанной с электронной почтой.
QuarkleMotion
Кажется, это ответ на другой вопрос! Где widget.js и network.js? Этот ответ, похоже, не имеет транзитивной зависимости, что и усложняло исходный вопрос.
Беннет Макэлви
7

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

Библиотека использует import * asсинтаксис, а затем заменяет исходный экспортированный объект классом-заглушкой. Он сохраняет безопасность типов, поэтому ваши тесты будут прерваны во время компиляции, если имя метода было обновлено без обновления соответствующего теста.

Эту библиотеку можно найти здесь: ts-mock-imports .

EmandM
источник
1
Этому модулю нужно больше звезд github
SD
3

Я обнаружил, что этот синтаксис работает:

Мой модуль:

// File mymod.js
import shortid from 'shortid';

const myfunc = () => shortid();
export default myfunc;

Тестовый код моего модуля:

// File mymod.test.js
import myfunc from './mymod';
import shortid from 'shortid';

jest.mock('shortid');

describe('mocks shortid', () => {
  it('works', () => {
    shortid.mockImplementation(() => 1);
    expect(myfunc()).toEqual(1);
  });
});

См. Документацию .

нерфолог
источник
+1 и с некоторыми дополнительными инструкциями: похоже, работает только с модулями узлов, то есть с тем, что у вас есть на package.json. И что более важно, то, что не упоминается в документации Jest, передаваемая строка должна jest.mock()соответствовать имени, используемому в import / packge.json, а не имени константы. В документации они оба одинаковы, но с таким кодом, как import jwt from 'jsonwebtoken'вам нужно настроить mock asjest.mock('jsonwebtoken')
kaskelotti
1

Сам не пробовал, но думаю, что издевательство может сработать. Это позволяет вам заменить настоящий модуль предоставленным вами макетом. Ниже приведен пример, чтобы дать вам представление о том, как это работает:

mockery.enable();
var networkMock = {
    getDataFromServer: function () { /* your mock code */ }
};
mockery.registerMock('network.js', networkMock);

import { Widget } from 'widget.js';
// This widget will have imported the `networkMock` instead of the real 'network.js'

mockery.deregisterMock('network.js');
mockery.disable();

Кажется, что mockeryбольше не поддерживается, и я думаю, что он работает только с Node.js, но, тем не менее, это отличное решение для имитации модулей, которые иначе сложно имитировать.

Эрик Б.
источник
1

Я недавно обнаружил babel-plugin-mockable-import, который аккуратно решает эту проблему, ИМХО. Если вы уже используете Babel , стоит изучить его.

Доминик П
источник
0

См. Предположим, я хотел бы имитировать результаты, возвращаемые isDevMode()функцией, чтобы проверить, как код будет вести себя при определенных обстоятельствах.

Следующий пример протестирован на следующей настройке

    "@angular/core": "~9.1.3",
    "karma": "~5.1.0",
    "karma-jasmine": "~3.3.1",

Вот пример простого сценария тестового случая

import * as coreLobrary from '@angular/core';
import { urlBuilder } from '@app/util';

const isDevMode = jasmine.createSpy().and.returnValue(true);

Object.defineProperty(coreLibrary, 'isDevMode', {
  value: isDevMode
});

describe('url builder', () => {
  it('should build url for prod', () => {
    isDevMode.and.returnValue(false);
    expect(urlBuilder.build('/api/users').toBe('https://api.acme.enterprise.com/users');
  });

  it('should build url for dev', () => {
    isDevMode.and.returnValue(true);
    expect(urlBuilder.build('/api/users').toBe('localhost:3000/api/users');
  });
});

Примерное содержание src/app/util/url-builder.ts

import { isDevMode } from '@angular/core';
import { environment } from '@root/environments';

export function urlBuilder(urlPath: string): string {
  const base = isDevMode() ? environment.API_PROD_URI ? environment.API_LOCAL_URI;

  return new URL(urlPath, base).toJSON();
}
Находкин
источник