Mock ECMAScript Modules in Vitest
How to Mock ECMAScript Modules in Vitest and Why You Need to Do It This Way.
This tutorial will:
- Show you the basic software pattern you need to intercept functions coming from ECMAScript modules and replace them with mocked objects for testing in Vitest.
- Explain why this approach is necessary in ECMAScript.
This is necessary for mocking direct function imports from packages written in EMS-style TypeScript or JavaScript. I use TypeScript in this tutorial.
Step 1 — The Pattern You Need
Mocking the Entire Module
To mock functions whilst testing without a dependency injection nightmare, you need to mock the entire module. To do this, use the following pattern at the top of your test file:
import vi from "vitest";
vi.mock("<your-imported-package>", async () => {
const actual = await vi.importActual("<your-imported-package>");
return {
...actual,
aMockedFunction: vi.fn(),
aMockedConstant: someValue // Whatever constant value you like
};
});
import { aMockedFunction } from "<your-imported-package>";
This code works by:
- Intercepting any module import of
<your-imported-package>with thevi.mockmethod call. - Importing all of the actual imports from that module with
vi.importActual. - Using the spread operator to populate a JavaScript object with all of the standard imports.
- Overwriting specific imports by making them Vitest mocked functions with:
aMockedFunction: vi.fn(). - Overwriting specific constant imports by forcing the constant to be a value you define. This should match the original constant’s type for compatibility, but this isn’t strictly necessary.
This new structure is returned as a JavaScript object and allows for import statements like:
import { aMockedFunction, aStandardFunction } from "<your-imported-package>"
To continue to work perfectly without any changes to your source code.
Examples
There are two common reasons you need to use this. First, mocking an NPM package. I’ll use firebase-admin as an example:
import vi from "vitest";
vi.mock("firebase-admin", async () => {
const actual = await vi.importActual("firebase-admin");
return {
...actual,
admin: vi.fn(),
};
});
import { admin } from "firebase-admin";
The second is mocking a module you’ve written yourself:
import vi from "vitest";
vi.mock("path/to/my/own/source/module.ts", async () => {
const actual = await vi.importActual("path/to/my/own/source/module.ts");
return {
...actual,
someFunction: vi.fn(),
someConstant: "hi",
};
});
import { someFunction } from "path/to/my/own/source/module.ts";
How To Use the Mocks
Now we can use the vi.fn() functions we’ve set up as mocks in our test files in the following way:
const mockAMockedFunction = vi.mocked(aMockedFunction);
mockAMockedFunction.mockReturnValue(<some-return-value>);
// ...(used in test)...
mockAMockedFunction.mockClear();
This is what’s happening:
vi.mockedis used to get a reference toaMockedFunctionthat includes the Vitest methods. In other words, if you tried to getaMockedFunctiondirectly, the method call.mockResolvedValuewould give you an error in TypeScript.- With the typed reference, we use
.mockReturnValue(...)to provide a return value from the function when it’s called during the test. This is what enables us to control the flow of our tests and maintain their repeatability. // ...(used in test)represents all the tests completed between the mock setup and the final line..mockReset()removes all of the mock’s history (call history, argument history, etc.) and resets the implementation. This means it will now return a defaultundefinedinstead of the<some-return-value>you previously set up.
There you have it. Use this wisely, as this is a module-level mock, so anywhere a mocked function is used, it must return something sensible!
Step 2 — Why This Pattern?
You don’t need to understand this to use the method above, but I think the knowledge helps anyway. The issue with testing ECMAScript Modules is the following:
- As the JavaScript ecosystem developed, multiple ways to handle modules evolved. For example, the CommonJS method was pioneered by Node.js in 2009 for server-side code, and AMD via Require.js for browser code.
- Different methods became a headache for developers who wanted a single common way to use modules across their code bases.
- This led to the creation of ECMAScript Modules and the
importstatement, which produces “read-only live bindings”. - The
importstatements must be at the top level, and can be statically analysed, allowing performance optimisations, better tooling, and code reduction methods like tree-shaking. - However, to ensure static analysis works correctly, modules imported via
importstatements cannot be dynamically altered. - This means you cannot dynamically replace a function with a mocked function during a test. If you want to mock something, you have to intercept the module import itself! Hence, the method in this tutorial.
So, when you use this pattern:
import vi from "vitest";
vi.mock("<your-imported-package>", async () => {
const actual = await vi.importActual("<your-imported-package>");
return {
...actual,
aMockedFunction: vi.fn(),
aMockedConstant: someValue // Whatever constant value you like
};
});
import { aMockedFunction } from "<your-imported-package>";
During test time, Vitest sees you have a vi.mock call and hoists it above the imports so the interception code is initialised first. This allows you to intercept the module as it imports and return your mocked function.
Final Words
The JavaScript/ Typescript testing setup feels like a minefield because of the language’s history. Hopefully, this helps you navigate testing with greater ease and gives you some idea of what has led to the confusion!