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
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 onGreeter.prototype
instead of onGreeter
.
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 theimport
keyword. You need to userequire()
or dynamicimport()
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 .