Mock window.matchMedia in Vitest

September 14, 2024 | 6 minutes
TL;DR: With Vitest, you can mock window.matchMedia using vi.hoisted() or a setup file so that it's hoisted and runs before your the imports in your tests are run and evaluated. Mocking multiple return values means using vi.hoisted() and a separate test file for each return value.

I'm still relatively new to Vitest and ran into some trouble mocking the window.matchMedia function recently. Mocking the value directly in the individual tests like below didn't work. Neither did moving the Object.defineProperty code block to the top of the test file.

1import { describe, it, vi, expect } from 'vitest';
2import { prefersReducedMotion } from '../prefersReducedMotion.js';
3
4describe("prefersReducedMotion", () => {
5  // ❌ Won't work
6  it("should return true if prefers reduced motion", () => {
7    Object.defineProperty(window, "matchMedia", {
8      writable: true,
9      enumerable: true,
10      value: vi.fn().mockImplementation((query) => ({
11        matches: false,
12        media: query,
13        onchange: null,
14        addListener: vi.fn(), // deprecated
15        removeListener: vi.fn(), // deprecated
16        addEventListener: vi.fn(),
17        removeEventListener: vi.fn(),
18        dispatchEvent: vi.fn(),
19 })),
20 });
21
22    // ❌ Error: window.matchMedia is not a function
23    expect(prefersReducedMotion).toBe(false);
24 });
25});

These approaches don't work because they set the window.matchMedia function after the imported prefersReducedMotion variable is evaluated. It turns out that there are two ways to mock this global method, though, so that the function is mocked before the variable is evaluated. Both involve a JavaScript concept called hoisting.

What is Hoisting?

In JavaScript-based unit testing frameworks, there are generally two scenarios for running your mocks:

  1. During the execution of an individual test
  2. Before any of the tests in a file are run

The first approach is the most straightforward: mocks run synchronously in the order defined within the test. It also means that mocks are defined after the imports in the test file are run and evaluated. So, if an imported variable, function, or module needs a mock to be set in order for it to run, this approach will not work.

The second approach uses hoisting, which means that the mock is moved (by the testing framework) so that it runs at the top of the scope. The scope is the test file and the testing framework will run the mocks before any imports in the test files are run and evaluated, so that you can provide mocks that those imports can access. This approach can run logic before all tests or before all tests in a single test file depending on the approach taken. In testing frameworks like Vitest, there are some aspects of the global scope, such as window.matchMedia that you can only update before imports are evaluated.

Vitest Mock Hoisting and window.matchMedia

In Vitest, you can hoist mocks in three ways:

  • vi.mock()
  • vi.hoisted()
  • setupFiles

vi.mock()

The vi.mock() method is useful when you need to hoist a mock for an imported module. It takes the path to the module as the first argument and the second is a callback function that returns the mocked exports of the module. You can use this to mock modules in your codebase or third-party modules.

The structure looks like this:

1import { vi } from 'vitest';
2
3vi.mock('./path/to/module.js', () => {
4  return {
5    namedExport: mocks.namedExport,
6 }
7})

Because window isn't a module, we can't use this to mock window.matchMedia.

vi.hoisted()

The vi.hoisted() method can be used to run code that causes side effects before variables, functions, and modules are imported and tests are run. This can be used to update globals, such as the window object, or mocking dates.

After the test file runs, the mock is removed as part of the cleanup process. You can use this method at the top of the file, within setup methods like beforeEach, or inside individual tests and Vitest will still run the function before any of the imports are evaluated and the tests in the file run.

This method can be used to mock window.matchMedia.

1import { describe, it, vi, expect } from 'vitest';
2
3/**
4 * Note: This is outside all of the tests, but it doesn't
5 * have to be. Even if you include this within an individual test,
6 * Vitest will use hoisting to run this function before any of the
7 * imports or tests in the file.
8 * 
9 * This also means that if you use the `vi.hoisted()` method in
10 * multiple tests to hoist the same mock, only the results of the
11 * last one will exist when the tests are run.
12 */
13vi.hoisted(() => {
14  Object.defineProperty(window, "matchMedia", {
15    writable: true,
16    enumerable: true,
17    value: vi.fn().mockImplementation((query) => ({
18      matches: false,
19      media: query,
20      onchange: null,
21      addListener: vi.fn(), // deprecated
22      removeListener: vi.fn(), // deprecated
23      addEventListener: vi.fn(),
24      removeEventListener: vi.fn(),
25      dispatchEvent: vi.fn(),
26 })),
27 });
28});
29
30describe("prefersReducedMotion", () => {
31  // ✅ Will work because the window.matchMedia mock was hoisted
32  // and ran before the imports and test
33  it("should return true if prefers reduced motion", () => {
34    expect(prefersReducedMotion).toBe(false);
35 });
36});

One thing to be aware of when using the vi.hoisted() method is that if you want to mock the same module or global with different values between tests, you can't do that in the same file. The last time you use vi.hoisted() to create the mock is the last value of that mock that will exist when the tests are run. I haven't found a way around this other than to use separate test files for each permutation of the mocked return value.

setupFiles

When you create a Vitest configuration file, there's a property called setupFiles that you can use for mocks (and other logic) that you want to run before all of the test files. You can use this approach to run the same set of logic before each test file without having to copy the same logic to the top of every file.

Since this also runs before the imports and tests, it can be used to mock window.matchMedia, too. To do so, you need to create a setup file. The setup file is a JavaScript/TypeScript file that contains the logic you want to run before the tests. Here's an example for mocking the window.matchMedia function.

First, create a file (filename doesn't matter) with the following code. The vi.hoisted() method isn't necessary because this file will run before the imports and tests anyway.

1// vitest-setup.js (can be any file name)
2import { vi } from "vitest";
3
4Object.defineProperty(window, "matchMedia", {
5  writable: true,
6  enumerable: true,
7  value: vi.fn().mockImplementation((query) => ({
8    matches: false,
9    media: query,
10    onchange: null,
11    addListener: vi.fn(), // deprecated
12    removeListener: vi.fn(), // deprecated
13    addEventListener: vi.fn(),
14    removeEventListener: vi.fn(),
15    dispatchEvent: vi.fn(),
16 })),
17});

Then, in your Vitest configuration file, add the setupFiles property and include the path to the file you made above.

1// vitest.config.js
2import { defineConfig } from "vitest/config";
3
4export default defineConfig({
5  test: {
6    environment: "jsdom",
7    setupFiles: ["./vitest-setup.js"], // change this to the file path you created
8 },
9});

Now, when you run Vitest, the Object.defineProperty method will be run before all of the imports and tests and will be available between test files. This is useful if all of your logic needs a module or global to be mocked in the same way.

Limitations

Since mocks are hoisted and run before other logic in the test files, you generally can't reference variables outside a mock from within the mock. This makes it hard to hoist a mock and then adjust its return value between tests.

The only way I've found to do this for window.matchMedia is to use the vi.hoisted() method and have separate test files for testing the return value as true and false. It's not a big deal in this case since there are only two permutations, but it would be frustrating for instances of multiple permutations for the possible return values.