Mock window.matchMedia in Vitest
September 14, 2024 | 6 minutesIn This Post
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:
- During the execution of an individual test
- 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.