Comment se moquer des importations d'un module ES6?

142

J'ai les modules ES6 suivants:

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() {
    ...
  }
}

Je cherche un moyen de tester Widget avec une instance simulée de getDataFromServer. Si j'utilisais des <script>s séparés au lieu de modules ES6, comme dans Karma, je pourrais écrire mon test comme:

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

Cependant, si je teste les modules ES6 individuellement en dehors d'un navigateur (comme avec Mocha + babel), j'écrirais quelque chose comme:

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

D'accord, mais maintenant getDataFromServern'est pas disponible dans window(enfin, il n'y en a pas windowdu tout), et je ne connais pas un moyen d'injecter des trucs directement dans son widget.jspropre scope.

Alors, où dois-je partir d'ici?

  1. Existe-t-il un moyen d'accéder à la portée de widget.js, ou au moins de remplacer ses importations par mon propre code?
  2. Sinon, comment puis-je rendre Widgettestable?

Les choses que j'ai envisagées:

une. Injection manuelle de dépendances.

Supprimez toutes les importations widget.jset attendez de l'appelant qu'il fournisse les deps.

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

Je suis très mal à l'aise de gâcher l'interface publique de Widget comme celle-ci et d'exposer les détails de mise en œuvre. Ne pas aller.


b. Exposez les importations pour permettre de les moquer.

Quelque chose comme:

import { getDataFromServer } from 'network.js';

export let deps = {
  getDataFromServer
};

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

puis:

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

Ceci est moins invasif mais m'oblige à écrire beaucoup de passe-partout pour chaque module, et il y a toujours un risque que j'utilise getDataFromServerau lieu de deps.getDataFromServertout le temps. Je suis inquiet à ce sujet, mais c'est ma meilleure idée jusqu'à présent.

Kos
la source
S'il n'y a pas de support natif pour ce type d'importation, je penserais probablement à écrire un propre transformateur pour Babel convertissant votre importation de style ES6 en un système d'importation mockable personnalisé. Cela ajouterait à coup sûr une autre couche d'échec possible et modifierait le code que vous souhaitez tester, ....
t.niese
Je ne peux pas définir une suite de tests pour le moment, mais j'essaierais d'utiliser la fonction de jasmin createSpy( github.com/jasmine/jasmine/blob/… ) avec une référence importée à getDataFromServer à partir du module 'network.js'. Pour que, dans le fichier de tests du widget, vous importiez getDataFromServer, puis vous feriezlet spy = createSpy('getDataFromServer', getDataFromServer)
Microfed
La seconde hypothèse est de renvoyer un objet depuis le module 'network.js', pas une fonction. De cette façon, vous pourriez spyOnsur cet objet, importé du network.jsmodule. C'est toujours une référence au même objet.
Microfed
En fait, c'est déjà un objet, d'après ce que je peux voir: babeljs.io/repl
...
2
Je ne comprends pas vraiment comment l'injection de dépendances gâche Widgetl'interface publique de? Widgetest foiré sans deps . Pourquoi ne pas rendre la dépendance explicite?
thebearingedge

Réponses:

130

J'ai commencé à utiliser le import * as objstyle dans mes tests, qui importe toutes les exportations d'un module en tant que propriétés d'un objet qui peuvent ensuite être simulées. Je trouve que c'est beaucoup plus propre que d'utiliser quelque chose comme rewire ou proxyquire ou toute autre technique similaire. Je l'ai fait le plus souvent lorsque j'ai besoin de simuler des actions Redux, par exemple. Voici ce que je pourrais utiliser pour votre exemple ci-dessus:

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

Si votre fonction se trouve être une exportation par défaut, alors import * as network from './network'produirait {default: getDataFromServer}et vous pouvez vous moquer de network.default.

Carpeliam
la source
3
Utilisez-vous le import * as objseul dans le test ou également dans votre code régulier?
Chau Thai
37
@carpeliam Cela ne fonctionnera pas avec la spécification du module ES6 où les importations sont en lecture seule.
ashish
7
Jasmine se plaint, [method_name] is not declared writable or has no setterce qui a du sens puisque les importations es6 sont constantes. Existe-t-il un moyen de contourner le problème?
lpan
2
@Francisc import(contrairement à require, qui peut aller n'importe où) est hissé, vous ne pouvez donc pas techniquement importer plusieurs fois. On dirait que votre espion est appelé ailleurs? Afin d'éviter que les tests ne gâchent l'état (connu sous le nom de pollution de test), vous pouvez réinitialiser vos espions dans un afterEach (par exemple, sinon.sandbox). Jasmine, je crois, fait cela automatiquement.
carpeliam
11
@ agent47 Le problème est que, bien que la spécification ES6 empêche spécifiquement cette réponse de fonctionner, exactement comme vous l'avez mentionné, la plupart des gens qui écrivent importdans leur JS n'utilisent pas vraiment les modules ES6. Quelque chose comme webpack ou babel interviendra au moment de la construction et le convertira soit en son propre mécanisme interne pour appeler des parties distantes du code (par exemple __webpack_require__), soit en l'un des standards de facto pré-ES6 , CommonJS, AMD ou UMD. Et cette conversion ne respecte souvent pas strictement les spécifications. Donc, pour beaucoup de développeurs en ce moment, cette réponse fonctionne très bien. Pour l'instant.
daemonexmachina
31

@carpeliam est correct mais notez que si vous voulez espionner une fonction dans un module et utiliser une autre fonction dans ce module appelant cette fonction, vous devez appeler cette fonction dans le cadre de l'espace de noms des exportations, sinon l'espion ne sera pas utilisé.

Mauvais exemple:

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

Bon exemple:

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
la source
4
J'aimerais pouvoir voter cette réponse 20 fois de plus! Je vous remercie!
sfletche
Quelqu'un peut-il expliquer pourquoi c'est le cas? Exportes.myfunc2 () est-il une copie de myfunc2 () sans être une référence directe?
Colin Whitmarsh
2
@ColinWhitmarsh exports.myfunc2est une référence directe à myfunc2jusqu'à le spyOnremplace par une référence à une fonction d'espionnage. spyOnchangera la valeur de exports.myfunc2et le remplacera par un objet espion, alors qu'il myfunc2reste intact dans la portée du module (car spyOnil n'y a pas accès)
madprog
ne devrait pas importer avec *geler l'objet et les attributs de l'objet ne peuvent pas être modifiés?
agent47
1
Notez simplement que cette recommandation d'utiliser export functionavec exports.myfunc2est techniquement un mélange de syntaxe de module commonjs et ES6 et cela n'est pas autorisé dans les versions plus récentes de webpack (2+) qui nécessitent l'utilisation de la syntaxe du module ES6 tout ou rien. J'ai ajouté une réponse ci-dessous basée sur celle-ci qui fonctionnera dans les environnements stricts ES6.
QuarkleMotion
6

J'ai implémenté une bibliothèque qui tente de résoudre le problème de la simulation au moment de l'exécution des importations de classes Typescript sans avoir besoin de la classe d'origine pour connaître toute injection de dépendance explicite.

La bibliothèque utilise la import * assyntaxe, puis remplace l'objet exporté d'origine par une classe stub. Il conserve la sécurité de type afin que vos tests soient interrompus lors de la compilation si un nom de méthode a été mis à jour sans mettre à jour le test correspondant.

Cette bibliothèque se trouve ici: ts-mock-importations .

EmandM
la source
1
Ce module a besoin de plus d'étoiles github
SD
6

La réponse de @ vdloo m'a poussé dans la bonne direction, mais l'utilisation des mots-clés commonjs "exports" et du module ES6 "export" ensemble dans le même fichier ne fonctionnait pas pour moi (webpack v2 ou ultérieur se plaint). Au lieu de cela, j'utilise une exportation par défaut (variable nommée) enveloppant toutes les exportations de modules nommés individuels, puis j'importe l'exportation par défaut dans mon fichier de tests. J'utilise la configuration d'exportation suivante avec mocha / sinon et le stubbing fonctionne bien sans avoir besoin de recâblage, 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
la source
Réponse utile, merci. Je voulais juste mentionner que le let MyModulen'est pas nécessaire pour utiliser l'exportation par défaut (il peut s'agir d'un objet brut). De plus, cette méthode ne nécessite pas myfunc1()d'appeler myfunc2(), elle fonctionne simplement pour l'espionner directement.
Mark Edington
@QuarkleMotion: Il semble que vous ayez modifié ceci avec un compte différent de votre compte principal par accident. C'est pourquoi votre modification a dû passer par une approbation manuelle - il ne semblait pas que cela provienne de vous, je suppose que c'était juste un accident, mais, si c'était intentionnel, vous devriez lire la politique officielle sur les comptes de marionnettes chaussettes afin que vous ne violez pas accidentellement les règles .
Compilateur remarquable
1
@ConspicuousCompiler merci pour la mise en garde - c'était une erreur, je n'avais pas l'intention de modifier cette réponse avec mon compte SO lié aux e-mails de travail.
QuarkleMotion
Cela semble être une réponse à une autre question! Où sont widget.js et network.js? Cette réponse semble n'avoir aucune dépendance transitive, ce qui a rendu la question initiale difficile.
Bennett McElwee
3

J'ai trouvé que cette syntaxe fonctionnait:

Mon module:

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

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

Code de test de mon module:

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

Voir la doc .

nerfologue
la source
+1 et avec quelques instructions supplémentaires: semble fonctionner uniquement avec les modules de nœuds, c'est-à-dire les éléments que vous avez sur package.json. Et plus important encore, quelque chose qui n'est pas mentionné dans la documentation Jest, la chaîne transmise jest.mock()doit correspondre au nom utilisé dans import / packge.json au lieu du nom de la constante. Dans la documentation, ils sont tous les deux identiques, mais avec un code comme import jwt from 'jsonwebtoken'vous devez configurer le simulacre commejest.mock('jsonwebtoken')
kaskelotti
0

Je ne l'ai pas essayé moi-même, mais je pense que la moquerie pourrait fonctionner. Il vous permet de remplacer le module réel par une maquette que vous avez fournie. Voici un exemple pour vous donner une idée de son fonctionnement:

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

Il semble que ce mockeryn'est plus maintenu et je pense que cela ne fonctionne qu'avec Node.js, mais néanmoins, c'est une solution intéressante pour se moquer des modules qui sont autrement difficiles à se moquer.

Erik B
la source