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.
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)
})
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
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
.
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)
})
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:
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()
}
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()
})
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.mock()
you cannot use the import
keyword. You need to use require()
or dynamic import()
instead.import
is
still unstable
Show archive.org snapshot
.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 .