TLDR (deepMock implementation & example test code)
Most JavaScript test frameworks provide a way to "mock" a dependency. This is great because it lets you write better test code, but it can quickly get verbose if the module to mock is coupled tightly with the code under test.
Here's an example from Jest documentation:
jest.mock('../foo-bar-baz', () => {
const originalModule = jest.requireActual('../foo-bar-baz');
return {
__esModule: true,
...originalModule,
default: jest.fn(() => 'mocked baz'),
foo: 'mocked foo',
};
});
That's a piece of code to replace just 2 properties of a module. You can see the jest.mock
call will get longer if
- There are more properties to mock.
- Each fake values are more complex.
This is a hassle because oftentimes, we don't care what the fake object does. We just want to avoid calling the actual dependency (like payment module, db access, etc) while not breaking the code under test.
Wouldn't it be nice if we could do this instead?
jest.mock('../foo-bar-baz', () => mockEverything());
Requirements
Let's think about what's required to build this magic. First of all, mockEverything
needs to replace each child property with a mock.
// Requirement 1
const myMock = mockEverything();
myMock.a // <- this should be a mock too.
That means a child of a child should also be a mock. In fact, all nested properties should be a mock.
// Requirement 1 - corollary
const myMock = mockEverything();
myMock.a.b.c // <- this should be a mock.
Secondly, return value of a mock method should be a mock. If a mock method returns any other concrete value, there's a possibility of breaking the code under test.
// Requirement 2
const myMock = mockEverything();
const value = myMock(); // <- this should be a mock.
That looks like a good starting point. Let's attempt to implement this magical thing!
Building Deep Mock
JavaScript's built-in Proxy
is perfect for this use-case because Proxy
lets us redefine fundamental object operations like getting a property or calling a function.
Iteration 1
To satisfy Requirement 1, we can do something like this:
function deepMock() {
return new Proxy(
["MOCK"], // object to "wrap". can be anything.
{
get() {
// Redefine property getter.
// Regardless of which key is requested,
// always return another mock.
return deepMock();
},
},
);
}
Let's check.
const myMock = deepMock();
console.log(myMock.a); // MOCK
console.log(myMock.a.b.c); // MOCK
Ok it works as expected! Now onto Requirement 2:
function deepMock() {
return new Proxy(
["MOCK"],
{
get() {
return deepMock();
},
apply() {
// Redefine function call.
// Always return another mock.
return deepMock();
},
},
);
}
const myMock = deepMock();
console.log(myMock()); // MOCK
Great, are we done?
Problem: Because our proxy returns a new object every time, it can do funky things like this.
const myMock = deepMock();
myMock.a === myMock.a // false
Iteration 2
Let's fix that by adding a little cache in the closure.
// Use Symbol to avoid name collision with other properties.
const ReturnSymbol = Symbol("proxy return");
function deepMock() {
const cache = {}; // cache in closure
return new Proxy(
["MOCK"],
{
get(target, prop) {
// Use the cached value if it exists.
if (prop in cache) {
return cache[prop];
}
// Otherwise, save a new mock to the cache and return.
return (cache[prop] = deepMock());
},
apply() {
// Similar to "get" above.
if (ReturnSymbol in cache) {
return cache[ReturnSymbol];
}
return (cache[ReturnSymbol] = deepMock());
},
},
);
}
Cool, now property comparison works normally.
const myMock = deepMock();
myMock.a === myMock.a // true
Problem: there is still a problem when mocking a promise. JS engine calls .then
method recursively to await
a value. Take a look at this example.
const myMock = deepMock();
await myMock; // never ends
It falls into an infinite recursion of myMock.then(x => x.then(y => y.then(...
.
Iteration 3
We can fix the infinite recursion by limiting the number of consecutive .then
calls.
const ReturnSymbol = Symbol("proxy return");
const ResolveSymbol = Symbol("proxy resolve");
// Set the limit to something practical.
const DEFAULT_PROMISE_DEPTH = 20;
function deepMock(promiseDepth = DEFAULT_PROMISE_DEPTH) {
const cache = {}; // cache in closure
return new Proxy(
["MOCK"],
{
get(target, prop) {
if (prop in cache) {
return cache[prop];
}
if (prop === "then" && promiseDepth === 0) {
// break the loop when it hits the limit.
return undefined;
}
return (cache[prop] =
prop === "then"
// recursively resolve as another mock with 1 less depth limit.
? (resolve) =>
resolve((cache[ResolveSymbol] ??= deepMock(promiseDepth - 1)))
: deepMock());
},
apply() {
if (ReturnSymbol in cache) {
return cache[ReturnSymbol];
}
return (cache[ReturnSymbol] = deepMock());
},
},
);
}
Awesome! Our implementation is getting longer, but await
does not hang anymore.
Problem: there is a problem when mocking a class.
const myMock = deepMock();
new myMock(); // TypeError: myMock is not a constructor
Iteration 4
We can support class mocking by changing the proxy target to a class and redefining construct
operation.
const ReturnSymbol = Symbol("proxy return");
const ResolveSymbol = Symbol("proxy resolve");
const DEFAULT_PROMISE_DEPTH = 20;
function deepMock(promiseDepth = DEFAULT_PROMISE_DEPTH) {
const cache = {};
return new Proxy(
// anonymous class as the proxy target.
class {},
{
get(target, prop) {
if (prop in cache) {
return cache[prop];
}
if (prop === "then" && promiseDepth === 0) {
return undefined;
}
return (cache[prop] =
prop === "then"
? (resolve) =>
resolve((cache[ResolveSymbol] ??= deepMock(promiseDepth - 1)))
: deepMock());
},
apply() {
if (ReturnSymbol in cache) {
return cache[ReturnSymbol];
}
return (cache[ReturnSymbol] = deepMock());
},
// Redefine construct operation.
construct() {
return deepMock();
},
},
);
}
It's great that we can mock classes, but we have a new problem.
Problem: now it throws an error when we try to convert a mock into a string.
const myMock = deepMock();
`${myMock}` // TypeError: Cannot convert object to primitive value
String(myMock) // TypeError: Cannot convert object to primitive value
Iteration 5
We can handle this edge case by adding Symbol.toPrimitive
and toString
methods to our proxy.
const ReturnSymbol = Symbol("proxy return");
const ResolveSymbol = Symbol("proxy resolve");
const DEFAULT_PROMISE_DEPTH = 20;
function deepMock(promiseDepth = DEFAULT_PROMISE_DEPTH) {
const cache = {};
return new Proxy(
class {},
{
get(target, prop) {
if (prop in cache) {
return cache[prop];
}
if (prop === "then" && promiseDepth === 0) {
return undefined;
}
// Provide string conversion methods.
if (prop === Symbol.toPrimitive || prop === "toString") {
return () => "<mock>"; // return any string here.
}
return (cache[prop] =
prop === "then"
? (resolve) =>
resolve((cache[ResolveSymbol] ??= deepMock(promiseDepth - 1)))
: deepMock());
},
apply() {
if (ReturnSymbol in cache) {
return cache[ReturnSymbol];
}
return (cache[ReturnSymbol] = deepMock());
},
construct() {
return deepMock();
},
},
);
}
We're almost there.
One last thing: Jest mock provides features like altering the return value, inspecting call history, etc. We'd like to expose those features to deepMock
users.
Iteration 6
Instead of making apply
operation return a mock right away, we can wrap it once with jest.fn
.
const ReturnSymbol = Symbol("proxy return");
const ResolveSymbol = Symbol("proxy resolve");
// Symbol for caching `jest.fn`
const MockSymbol = Symbol("mock");
const DEFAULT_PROMISE_DEPTH = 20;
function deepMock(promiseDepth = DEFAULT_PROMISE_DEPTH) {
const cache = {
// This Jest mock gets called inside the proxy apply operation.
[MockSymbol]: jest.fn(() =>
ReturnSymbol in cache
? cache[ReturnSymbol]
: (cache[ReturnSymbol] = deepMock())
),
};
return new Proxy(
class {},
{
get(target, prop) {
if (prop in cache) {
return cache[prop];
}
if (prop === "then" && promiseDepth === 0) {
return undefined;
}
// Provide string conversion methods.
if (prop === Symbol.toPrimitive || prop === "toString") {
return () => "<mock>"; // return any string here.
}
return (cache[prop] =
prop === "then"
? (resolve) =>
resolve((cache[ResolveSymbol] ??= deepMock(promiseDepth - 1)))
: deepMock());
},
// forward args to the Jest mock.
apply(target, thisArg, args) {
return cache[MockSymbol](...args)
},
construct() {
return deepMock();
},
},
);
}
/**
* Access the Jest mock function that's wrapping the given deeply mocked function.
* @param func the target function. It needs to be deeply mocked.
*/
export const mocked = (func) => func[MockSymbol];
Fantastic, this allows us to control deeply mocked methods through Jest.
const myMock = deepMock();
mocked(myMock.a.b.c.d).mockReturnValueOnce(42);
myMock.a.b.c.d(); // 42
Conclusion
Here's how our deepMock
function can be used!
// foo.js
export const DatabaseAdapter {
// ...
doSomethingCrazy() {...}
}
// bar.js
import {DatabaseAdapter} from "./foo";
export function legacyFunction() {
// imagine lots of code here...
DatabaseAdapter.doSomethingCrazy();
// ...
}
// bar.test.js
import {DatabaseAdapter} from "./foo";
import {legacyFunction} from "./bar";
import {mocked} from "./deepMock";
// Deeply mock foo.
jest.mock(
"./foo",
() => jest.requireActual("./deepMock").deepMock(),
);
test("legacy", () => {
// We can call legacyFunction without worrying about
// affecting database because foo is deeply mocked.
legacyFunction();
// But we can still test whether the database adapter
// is used properly.
expect(mocked(DatabaseAdapter.doSomethingCrazy)).toHaveBeenCalled();
});
And that's a wrap! Is this something you would use in your test code? What do you think about the deepMock
implementation? Could I do it better?
Notes
- If your dependency is too tightly coupled with your application code, the better way to deal with the problem is to reduce the coupling with techniques like inversion of control. Deep mocking is just a convenient workaround until a proper fix is available.
- The meaning of "mock", "stub", "fake", and "spy" can be different depending on which language/framework/library you use, but they are all some variant of test double.
Top comments (0)