Skip to main content

TDD - Mocking vue-router with vitest

Background

Our current project, both front-end and back-end, is based on TDD. For the front-end, we use vitest as the testing framework, along with vue-test-utils as the testing tool. Tech stack: TypeScript + Vue3 + vue-router + pinia + vitest.

Last week, a colleague mentioned that she was working on a feature to read the current user's country group permissions from the okta userinfo API. The code implementation was complete, but when submitting the code, she found that several existing test cases kept failing, all encountering the same problem:

TypeError: Cannot read properties of undefined (reading 'beforeEach')

At first, we didn't know the reason and couldn't think of a good solution, so we tried introducing a third-party library, vue-router-mock, to mock vue-router as a workaround.

I had encountered this problem before, but we always found other ways around it; this time, I thought there might be a way to solve it without introducing a new third-party library, so I researched and tried various methods, finally fixing the issue.

Problem

This is a failed test case excerpted from the countrySelectorComponent spec at the time, along with the code for the countryStore.

Running this test case resulted in an error, with the following message:

test-failed-beforeEach

TypeError: Cannot read properties of undefined (reading 'beforeEach')

The error originated from router/index.ts in the line router.beforeEach(() => {}).

router-index-ts

Analysis

At first, it was surprising why this error occurred, as we did not directly call router/index.ts in the test case, and we had already mocked vue-router at the beginning of this test file.

vi.mock("vue-router");

Theoretically, the code in router/index.ts should not have been executed.

Then, I carefully examined the error message,

It turned out that src/main.ts imported router/index.ts.

Following this train of thought, I analyzed this long dependency path.

src/tests/components/CountrySelectorComponent.spec.ts: import CountrySelectorComponent from " ../../components/CountrySelectorComponent.vue";

-->

src/components/CountrySelectorComponent.vue: import { useCountryStore } from "../stores/CountryStore";

-->

src/stores/CountryStore.ts: import { useUserStore } from "./UserStore.ts";

-->

src/stores/UserStore.ts: import { i18n } from "../main";

-->

src/main.ts: import router from "./router";

-->

src/router/index.ts: router.beforeEach(() => {})

Analyzing the layers of dependencies, it became clear that we indeed had a dependency on router/index.ts.

Additionally, we could further study the error stack -

TypeError: Cannot read properties of undefined (reading 'beforeEach')
at /Users/binliu.zhang/workspace/ivr-client/src/router/index.ts:49:8
at VitestExecutor.runModule (file:///Users/binliu.zhang/workspace/ivr-client/node_modules/vite-node/dist/client.mjs:341:5)
at VitestExecutor.directRequest (file:///Users/binliu.zhang/workspace/ivr-client/node_modules/vite-node/dist/client.mjs:325:5)
at VitestExecutor.cachedRequest (file:///Users/binliu.zhang/workspace/ivr-client/node_modules/vite-node/dist/client.mjs:188:14)
at VitestExecutor.dependencyRequest (file:///Users/binliu.zhang/workspace/ivr-client/node_modules/vite-node/dist/client.mjs:222:12)

To understand what this code does(Vitest source code), I enabled debug breakpoints and found that vitest performs static analysis on the dependencies of a test case before executing it. It runs dependencyRequest for each module dependency and then runModule.

We can see it will load mocks first, then request the real dependencies and run them.

We called vi.mock("vue-router") at the very beginning of our test file. It will always be executed before all imports. However, in router/index.ts, we found that router actually comes from createRouter --


// router/index.ts
import {createRouter, createWebHistory} from "vue-router";

const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes,
});
router.beforeEach(() => {
});

But now createRouter is just a mocked empty function () => {}, calling it will only return undefined, so router is also undefined, hence the error Cannot read properties of undefined (reading 'beforeEach').

Solution

Now that we know where the problem lies, solving it becomes relatively simple. Considering we do not use the code in router/index.ts in our test case, we can mock this file router/index.ts in test/setup.ts, so that the code in router/index.ts will not be executed.

// test/setup.ts

import {vi} from "vitest";

vi.mock("../router/index.ts", () => ({
default: {},
}));

After adding this code, the problem was resolved.

Further Thoughts

After initially resolving the issue, I continued to ponder whether we could solve this problem without mocking router/index.ts in test/setup.ts, but instead mock createRouter in the test case as we did before with useRouter & useRoute. I tried the following code, but it didn't work.

// won't work

vi.mock("vue-router");
const mockRouter = {
push: vi.fn(),
};
test("should display the right amount of list items", async () => {
(useRoute as Mock).mockReturnValue({
params: {countryCode: countryList[0].code},
});
(useRouter as Mock).mockReturnValue({});

(createRouter as Mock).mockReturnValue(mockRouter);
});

Running the test case, the same error occurs

TypeError: Cannot read properties of undefined (reading 'beforeEach')

at /Users/binliu.zhang/workspace/ivr-client/src/router/index.ts:49:8

Why is this? We know that createRouter is called in router/index.ts. As mentioned earlier, because vitest first analyzes dependencies VitestExecutor.dependencyRequest() and then runs VitestExecutor.runModule(), this process happens before the actual test case code runs, so this approach is not feasible.

Exploring further, if we do not mock router/index.ts but

instead use importOriginal to mock createRouter at the top of the test file, it works.

// works, but not recommended
// test spec file
const mockRouter = {
push: vi.fn(),
};
vi.mock("vue-router", async (importOriginal) => {
const mod = await importOriginal<typeof import("vue-router")>();
return {
...mod,
// replace some exports
createRouter: () => ({
push: vi.fn(),
beforeEach: vi.fn(),
}),
useRoute: () => ({
params: {countryCode: countryList[0].code},
}),
useRouter: () => mockRouter, // NOTE: must return a reference to the same object, otherwise `spyOn` won't work
};
});

test("should display the right amount of list items", async () => {
// ...
});

However, in a single file, for each test case, useRouter & useRoute always return the same mock values, which is not very flexible as it cannot dynamically assign values based on different test cases.

In conclusion, we chose to mock router/index.ts in test/setup.ts, so for each test case, we can mock the return values of useRouter & useRoute as needed.

// test/setup.ts
vi.mock("../router/index.ts", () => ({
default: {},
}));