DEV Community

Cover image for Deep Mocking for Jest
Danny Kim
Danny Kim

Posted on • Edited on

Deep Mocking for Jest

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',
  };
});
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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();
      },
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's check.

const myMock = deepMock();
console.log(myMock.a); // MOCK
console.log(myMock.a.b.c); // MOCK
Enter fullscreen mode Exit fullscreen mode

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();
      },
    },
  );
}
Enter fullscreen mode Exit fullscreen mode
const myMock = deepMock();
console.log(myMock()); // MOCK
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
      },
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Cool, now property comparison works normally.

const myMock = deepMock();
myMock.a === myMock.a // true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
      },
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
      },
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
      },
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Conclusion

Here's how our deepMock function can be used!

// foo.js
export const DatabaseAdapter {
  // ...
  doSomethingCrazy() {...}
}
Enter fullscreen mode Exit fullscreen mode
// bar.js
import {DatabaseAdapter} from "./foo";
export function legacyFunction() {
   // imagine lots of code here...
   DatabaseAdapter.doSomethingCrazy();
   // ...
}
Enter fullscreen mode Exit fullscreen mode
// 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();
});
Enter fullscreen mode Exit fullscreen mode

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.

Reference

Top comments (0)