Read more

Jasmine: Mocking ESM imports

Henning Koch
December 22, 2022Software engineer at makandra GmbH

In a Jasmine Show archive.org snapshot spec you want to spy on a function that is imported by the code under test. This card explores various methods to achieve this.

Example

Illustration online protection

Rails professionals since 2007

Our laser focus on a single technology has made us a leader in this space. Need help?

  • We build a solid first version of your product
  • We train your development team
  • We rescue your project in trouble
Read more Show archive.org snapshot

We are going to use the same example to demonstrate the different approaches of mocking an imported function.

We have a module 'lib' that exports a function hello():

// lib.js

function hello() {
  console.log("hi world")
}

export hello

We have a second module 'client' that exports a function helloTwice(). All this does is call hello() two times:

// client.js

import { hello } from 'lib'

function helloTwice() {
  hello()
  hello()
}

export helloTwice

In our test we would like to show that helloTwice() calls hello() twice.
We find out that it's really hard to spy on the imported hello() function:

// test.js

import { hello } from 'lib'
import { helloTwice } from 'client'

it('says hello', function() {
  spyOn(????) // how to spy on hello()?
  helloTwice()
  expect(hello.calls.count()).toBe(2)
})

Workaround: Reconfigurable functions

The idea here is to write a function that can swap out its internal implementation with a Jasmine spy later. This adds a little noise to your module, but allows you to keep your API unchanged.

Below you can find a mockable() helper that lets us write a reconfigurable hello() function:

// lib.js

import { mockable } from mockable

const hello = mockable(function() {
  console.log("hi world")
})

export hello

No changes need to be made to the client code. We import and call a plain hello() function:

// client.js

import { hello } from 'lib'

function helloTwice() {
  hello()
  hello()
}

export helloTwice

Our tests also imports hello. To swap out its internal implementation with a Jasmine spy we call hello.mock():

// test.js

import { hello } from 'lib'
import { helloTwice } from 'client'

it('says hello twice', function() {
  // Replace the internal implementation with a Jasmine spy:
  const spy = hello.mock()
  helloTwice()
  expect(spy.calls.count()).toBe(2)
})

Here is the mockable() function as an ESM module. This needs to ship with your code (not just the tests):

// mockable.js
// Adapted from @evanw: https://github.com/evanw/esbuild/issues/412#issuecomment-723047255

function mockable(originalFn) {
  if (window.jasmine) {
    let name = originalFn.name
    let obj = { [name]: originalFn }
    let mockableFn = function() {
      return obj[name].apply(this, arguments)
    }
    mockableFn.mock = () => spyOn(obj, name) // eslint-disable-line no-undef
    return mockableFn
  } else {
    return originalFn
  }
}

export mockable

Workaround: Wrap your exports in an object

This requires no libraries and works in all build pipeline. However, you need to slightly change your API.

Instead of exporting the hello() function directly, we wrap it in a Greeter object and export that:

// lib.js

const Greeter = {
  hello() {
    console.log("hi world")
  }
}

export Greeter

We must now use Greeter.hello() instead of just hello():

// client.js

import { Greeter } from 'lib'

function helloTwice() {
  Greeter.hello()
  Greeter.hello()
}

export helloTwice
// test.js

import { Greeter } from 'lib'
import { helloTwice } from 'client'

it('says hello twice', function() {
  spyOn(Greeter, 'hello')
  helloTwice()
  expect(Greeter.hello.calls.count()).toBe(2)
})

Info

Instead of wrapping our function in a simple object, we may also deliver it as a full ES6 class.
In this case we would spy on Greeter.prototype instead of on Greeter.

Workaround: Dependency injection

Dependency Injection or Inversion of Control is a classical pattern where the dependencies of a module can be reconfigured from the outside. This makes code more testable in many cases.

Under this pattern we keep the original implementation of 'lib' (which has no dependencies of its own):

// lib.js

function hello() {
  console.log("hi world")
}

export hello

We change helloTwice() so it optionally accepts a different hello() implementation. Only no function is passed we default to the imported implementation:

// client.js

import { hello } from 'lib'

function helloTwice(helloFn = hello) {
  helloFn()
  helloFn()
}

export helloTwice

In our test we can now call sayHelloTwice() with a Jasmine spy as an argument:

// test.js

import { helloTwice } from 'client'

it('says hello', function() {
  const mockedHello = jasmine.spyOn('hello spy')
  helloTwice(mockedHello)
  expect(mockedHello.calls.count()).toBe(2)
})

Workaround: Exploit Webpack's internal transpilation format

Webpack 4 happens to transpile ESM modules in a way that you can spy on imported functions trivially.

While this approach has no impact on your code, it's not very future-proof:

  • It breaks the ESM spec that demands imported modules to be read-only.
  • It only works in some versions of Webpack (tested on Webpacker 5.4 with Webpack 4.46).
  • It does not work on esbuild.
  • If you ever want to upgrade your build tools, you need to refactor your code and tests.

With this approach we do not need to change our 'lib' module:

// lib.js

function hello() {
  console.log("hi world")
}

export hello

We do not need to change our client code either:

// client.js

import { hello } from 'lib'

function helloTwice() {
  hello()
  hello()
}

export helloTwice

In our tests we can import the entire 'lib' module using import *, and then mock on the resulting object:

// test.js

import * as lib from 'lib'
import { helloTwice } from 'client'

it('says hello twice', function() {
  spyOn(lib, 'hello')
  helloTwice()
  expect(Greeter.hello.calls.count()).toBe(2)
})

The reason why this works is that, internally, Webpack transpiles our 'client' to something like this:

const lib = require('lib')

function helloTwice() {
  lib.hello()
  lib.hello()
}

Workaround: Use a Babel plugin

There is a plugin babel-plugin-mockable-imports Show archive.org snapshot that lets you mock imports. I do not know how this one works internally, but you can see the use in your code below.

There is no equivalent plugin for esbuild. While you could add Babel to esbuild, having a slow, JS-based build step defeats the purpose of a fast esbuild setup.

Using the plugin we do not need to change our 'lib' module:

// lib.js

function hello() {
  console.log("hi world")
}

export hello

We can also keep our client code unchanged:

// client.js

import { hello } from 'lib'

function helloTwice() {
  hello()
  hello()
}

export helloTwice

In our tests our modules now export a new { $imports } property we can use to mock its imports after the fact:

// test.js

import { $imports } from 'lib'
import { helloTwice } from 'client'

it('says hello twice', function() {
  const mockedSayHello = jasmine.createSpy('mocked hello()')
  $imports.$mock({
    'lib': {
      hello: mockedSayHello
    }
  })

  helloTwice()
  expect(mockedSayHello.calls.count()).toBe(2)
})

afterEach(function() {
  $imports.$restore()
})

Workaround: Use Jest

Jest Show archive.org snapshot is a test runner supports mocking a module's dependencies. This has some drawbacks for frontend JavaScript that targets the browser:

  • Jest only runs in Node.js. This means your specs can no longer run in a real browser. You must emulate a browser-like environment with something like jsdom Show archive.org snapshot and hope that all the browser APIs you use are simulated properly. You can however connect the Chrome DevTools to Node's test process.
  • Jest must be in control of building your code. You can no longer use your regular build pipeline.
  • To use jest.mock() you cannot use the import keyword. You need to use require() or dynamic import() instead.
  • Using plain import is still unstable Show archive.org snapshot .

Workaround: Temporarily change your import map

When you don't use a bundler and rely on the browser to load your module through import maps, there is another workaround available Show archive.org snapshot .

Henning Koch
December 22, 2022Software engineer at makandra GmbH
Posted by Henning Koch to makandra dev (2022-12-22 11:04)