bookmark_borderJavaScript / Jest – Comment gérer les mocks

Pour faciliter les explications, voici une fonction triviale, qu’on peut supposer être une fonction importante à mocker.

exports.bob = () => {
  // fonction très TRÈS complexe...
  return "real";
}

Problème: Les mocks ne se réinitialisent pas automatiquement après chaque test. Il faut le faire manuellement et il y a deux méthodes équivalentes.

const index = require("./index")

test("test 1", () => {
  const original = index.bob;
  index.bob = jest.fn(() => 0);
  expect(index.bob(0)).toBe("mocked");
  index.bob = original; // <-- on remet la fonction originale en place
});

Ou encore

test("test 1", () => {
  const bob = jest.spyOn(index, "bob");
  bob.mockImplementation(() => "mocked");
  expect(index.bob(0)).toBe("mocked");
  addMock.mockRestore(); // <-- on remet la fonction originale en place
});

Oui, ça fonctionne, le hic c’est que si un test plante avant de réinitialiser le mock, les autres tests qui dépendent de ladite fonction planteront. Par exemple:

test("test 1", () => {
  original = index.bob;
  index.bob = jest.fn(() => 0);
  expect(index.bob(0)).toBe("mocked");
  throw "erreur"; // oups!
  index.bob = original; // ça n'arrive pas
});

test("test 2", () => {
  expect(index.bob(7)).toBe("real"); // va échouer car bob retourne encore 0
})

Solution 1 – try / finally

test("test 1", () => {
  original = index.bob;
  try {
    index.bob = jest.fn(() => "mocked");
    expect(index.bob(10)).toBe("mocked");
    throw "erreur"; // oups!
  } finally {
    index.bob = original;
  }
});

test("test 2", () => {
  expect(index.bob(7)).toBe("real");
})

C’est n’est pas tellement élégant car ça indente le code. C’est aussi facile d’oublier de mettre l’étape de réinitialisation dans un finally.

Solution 2 – Une fonction router / proxy

const original_bob = index.bob;
index.bob = jest.fn((arg) => {
  // un ou plusieurs if/else pour diriger les mocks
  if (arg === 0) {
    // mock pour "test 1"
    return "mocked";
  }

  // si rien ne correspond, on appelle la fonction originale
  return original_bob(arg);
});

test("test 1", () => {
  expect(index.bob(0)).toBe("mocked");
});

test("test 2", () => {
  expect(index.bob(10)).toBe("real");
})

Gros défaut: Ça ne fonctionne pas pour les fonctions qui n’ont pas d’argument. Autre inconvénient, la fonction peut rapidement devenir lourde s’il y a beaucoup de if / else. Pour ne pas perdre le fil, ça demande d’écrire des commentaires pour garder une trace de l’association entre chaque mock et test. Si on renomme un test sans mettre à jour le commentaire dans le mock, le lien est rompu.

De l’autre côté, cette approche a l’avantage de pouvoir réutiliser les mocks entre les tests sans devoir les reconfigurer à chaque test. De plus, elle n’exige pas au programmeur de devoir se rappeler à chaque fois d’encapsuler le mock dans un bloc try / finally et elle ne crée pas d’indentation du code.

Conclusion

Je théorise que la source du problème est probablement attribuable à l’absence de destructeurs en JavaScript, ce qui empêche d’encapsuler les mocks dans des objets et d’utiliser la technique resource acquisition is initialization, aussi connue sous RAII.

Merci JavaScript. Merci.

Références