Como zombar das importações de um módulo ES6?

141

Eu tenho os seguintes módulos 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() {
    ...
  }
}

Estou procurando uma maneira de testar o Widget com uma instância simulada de getDataFromServer. Se eu usasse <script>s separados em vez de módulos ES6, como no Karma, poderia escrever meu teste como:

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();
  });
});

No entanto, se eu estiver testando os módulos ES6 individualmente fora de um navegador (como no Mocha + babel), escreveria algo como:

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();
  });
});

Ok, mas agora getDataFromServernão está disponível no window(bem, não existe window) e não sei como injetar coisas diretamente no widget.jspróprio escopo.

Então, para onde eu vou daqui?

  1. Existe uma maneira de acessar o escopo widget.jsou, pelo menos, substituir suas importações por meu próprio código?
  2. Caso contrário, como posso fazer o teste Widget?

Coisas que eu considerei:

uma. Injeção de dependência manual.

Remova todas as importações widget.jse espere que o chamador forneça os deps.

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

Estou muito desconfortável por atrapalhar a interface pública do Widget assim e expor os detalhes da implementação. Não vá.


b. Exponha as importações para permitir zombar delas.

Algo como:

import { getDataFromServer } from 'network.js';

export let deps = {
  getDataFromServer
};

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

então:

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();
  });
});

Isso é menos invasivo, mas requer que eu escreva um monte de clichê para cada módulo, e ainda há o risco de eu usá-lo em getDataFromServervez de deps.getDataFromServero tempo todo. Estou desconfortável com isso, mas essa é a minha melhor ideia até agora.

Kos
fonte
Se não houver suporte de simulação nativo para esse tipo de importação, provavelmente pensaria em escrever um próprio transformador para babel, convertendo sua importação de estilo ES6 para um sistema de importação simulável personalizado. Isso com certeza adicionaria outra camada de possível falha e mudaria o código que você deseja testar,.
t.niese
Não posso definir um conjunto de testes no momento, mas tentaria usar a função jasmin createSpy( github.com/jasmine/jasmine/blob/… ) com uma referência importada para getDataFromServer do módulo 'network.js'. Para que, no arquivo de testes do widget, você importasse getDataFromServer e, então, let spy = createSpy('getDataFromServer', getDataFromServer)
importaria
O segundo palpite é retornar um objeto do módulo 'network.js', não uma função. Dessa forma, você poderia spyOnnaquele objeto, importado do network.jsmódulo. É sempre uma referência ao mesmo objeto.
Microfed
Na verdade, já é um objeto, pelo que posso ver: babeljs.io/repl/…
Microfed
2
Eu realmente não entendo como a injeção de dependência atrapalha Widgeta interface pública da? Widgetestá bagunçado sem deps . Por que não tornar a dependência explícita?
thebearingedge

Respostas:

129

Comecei a empregar o import * as objestilo em meus testes, que importa todas as exportações de um módulo como propriedades de um objeto que pode ser zombado. Acho que isso é muito mais limpo do que usar algo como religar ou solicitar proxy ou qualquer técnica semelhante. Eu fiz isso com mais frequência ao precisar zombar de ações Redux, por exemplo. Aqui está o que eu poderia usar para o seu exemplo acima:

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();
  });
});

Se a sua função for uma exportação padrão, import * as network from './network'ela produzirá {default: getDataFromServer}e você poderá zombar de network.default.

Carpeliam
fonte
3
Você usa o import * as objúnico no teste ou também no seu código regular?
Chau Thai
36
@carpeliam Isso não funcionará com as especificações do módulo ES6, onde as importações são somente leitura.
ashish
7
O jasmim está reclamando, o [method_name] is not declared writable or has no setterque faz sentido, já que as importações da es6 são constantes. Existe uma maneira de contornar isso?
lpan 11/05/19
2
O @Francisc import(ao contrário require, que pode ir a qualquer lugar) é içado, então você não pode importar tecnicamente várias vezes. Parece que seu espião está sendo chamado em outro lugar? Para evitar que os testes atrapalhem o estado (conhecido como poluição de teste), você pode redefinir seus espiões em um afterEach (por exemplo, sinon.sandbox). Jasmine, acredito que faça isso automaticamente.
Carpeliam
10
@ agent47 O problema é que, embora as especificações do ES6 impeçam especificamente que essa resposta funcione, exatamente da maneira que você mencionou, a maioria das pessoas que escrevem importem seus JS não estão realmente usando os módulos do ES6. Algo como Webpack ou babel irá intervir em tempo de compilação e convertê-lo tanto em seu próprio mecanismo interno para chamar partes distantes do código (por exemplo __webpack_require__) ou em um dos pré-ES6 de facto normas, commonjs, AMD ou UMD. E essa conversão geralmente não segue estritamente as especificações. Portanto, para muitos, muitos desenvolvedores agora, essa resposta funciona bem. Por enquanto.
Daemonexmachina
31

@carpeliam está correto, mas observe que, se você quiser espionar uma função em um módulo e usar outra função nesse módulo que chama essa função, precisará chamá-la como parte do espaço para nome das exportações, caso contrário, o espião não será usado.

Exemplo errado:

// mymodule.js

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

// 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
    });
});

Exemplo certo:

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

// 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
    });
});
vdloo
fonte
4
Eu gostaria de poder votar esta resposta mais 20 vezes! Obrigado!
precisa
Alguém pode explicar por que esse é o caso? Export.myfunc2 () é uma cópia do myfunc2 () sem ser uma referência direta?
Colin Whitmarsh
2
@ColinWhitmarsh exports.myfunc2é uma referência direta a myfunc2até spyOnsubstitui-lo por uma referência a uma função espião. spyOnirá alterar o valor de exports.myfunc2e substituí-lo com um objeto de espionagem, enquanto myfunc2permanece intocado no escopo do módulo (porque spyOnnão tem acesso a ele)
madprog
a importação não deve *congelar o objeto e os atributos do objeto não podem ser alterados?
precisa saber é
1
Apenas observe que essa recomendação de uso export functionjuntamente exports.myfunc2combina tecnicamente a sintaxe do módulo commonjs e ES6 e isso não é permitido nas versões mais recentes do webpack (2+) que requerem o uso da sintaxe do módulo ES6 do tipo tudo ou nada. Adicionei uma resposta abaixo com base nesta que funcionará em ambientes estritos do ES6.
QuarkleMotion 19/05/19
6

Eu implementei uma biblioteca que tenta resolver o problema da zombaria em tempo de execução das importações da classe Typescript sem precisar da classe original para saber sobre qualquer injeção explícita de dependência.

A biblioteca usa a import * assintaxe e substitui o objeto exportado original por uma classe de stub. Ele retém a segurança de tipo, para que seus testes sejam interrompidos no tempo de compilação se um nome de método tiver sido atualizado sem a atualização do teste correspondente.

Esta biblioteca pode ser encontrada aqui: ts-mock-imports .

EmandM
fonte
1
Este módulo precisa de estrelas mais github
SD
6

A resposta da @ vdloo me levou na direção certa, mas o uso das palavras-chave "exportações" e do módulo ES6 "exportação" do commonjs juntos no mesmo arquivo não funcionou para mim (o webpack v2 ou posterior reclama). Em vez disso, estou usando uma exportação padrão (variável nomeada) agrupando todas as exportações de módulos nomeados individuais e importando a exportação padrão no meu arquivo de testes. Estou usando a seguinte configuração de exportação com mocha / sinon e o stubbing funciona bem sem a necessidade de religar, etc .:

// 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
fonte
Resposta útil, obrigado. Só queria mencionar que let MyModulenão é necessário usar a exportação padrão (pode ser um objeto bruto). Além disso, esse método não precisa myfunc1()ser chamado myfunc2(), funciona apenas para espioná-lo diretamente.
Mark Edington
@QuarkleMotion: parece que você editou isso com uma conta diferente da sua conta principal por acidente. É por isso que sua edição teve que passar por uma aprovação manual - não parecia que era de você , presumo que foi apenas um acidente, mas, se for intencional, você deve ler a política oficial sobre contas de bonecos de meias para que você não viole acidentalmente as regras .
Compilador conspícuo
1
@ConspicuousCompiler, obrigado pela atenção - este foi um erro. Não pretendia modificar esta resposta com a minha conta SO vinculada a email por trabalho.
QuarkleMotion 22/05/19
Esta parece ser uma resposta para uma pergunta diferente! Onde estão o widget.js e o network.js? Essa resposta parece não ter dependência transitiva, o que dificultou a pergunta original.
Bennett McElwee
3

Eu encontrei esta sintaxe para trabalhar:

Meu módulo:

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

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

Código de teste do meu módulo:

// 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);
  });
});

Veja o doc .

nerfologista
fonte
+1 e com algumas instruções adicionais: Parece funcionar apenas com módulos de nós, ou seja, com as coisas que você possui no package.json. E o mais importante é que, algo que não é mencionado nos documentos do Jest, a cadeia de caracteres transmitida jest.mock()deve corresponder ao nome usado em import / packge.json, em vez do nome da constante. Nos documentos que ambos são iguais, mas com um código como import jwt from 'jsonwebtoken'você precisa configurar o mock comojest.mock('jsonwebtoken')
kaskelotti
0

Eu ainda não tentei, mas acho que a zombaria pode funcionar. Ele permite que você substitua o módulo real por um mock que você forneceu. Abaixo está um exemplo para lhe dar uma idéia de como funciona:

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();

Parece que mockerynão é mais mantido e acho que funciona apenas com o Node.js, mas, mesmo assim, é uma solução interessante para zombar de módulos que, de outra forma, seriam difíceis de zombar.

Erik B
fonte