Testing is crucial in modern software development for ensuring code quality, reliability, and maintainability. However, the complexity of testing can often feel overwhelming.
In this article, we will first delve into real-world use cases to demonstrate testing strategies using Vitest and its various APIs. We will also share a cheat sheet with condensed Vitest examples that can serve as a useful resource for both aspiring testers and experienced developers.
Let’s get started!
If you are new to testing, it is worth familiarizing yourself with some of its important terms and concepts:
Because there are no official definitions, there are differing opinions on the differences between spies and mocks. This article defines spies as tools that do not change the original implementation of a module, using real actors to verify expected interactions. In contrast, mocks are fully or partly replaced modules with simplified functions to control tests. In your tests, you don’t want to make actual network calls, so you have to replace the module responsible for it with a mock.
For a deeper dive into testing, check out this guide to unit testing, a guide to unit and integration testing for Node.js apps, and a Vitest tutorial for automated testing using Vue components.
Now, let’s explore in detail how to write tests with Vitest.
The goal of this article is to teach you how to use Vitest’s API to write robust, readable, and maintainable tests. If you want to learn more about Vitest architecture, I recommend Ben Holmes’s video, which shows that Vitest features built-in modern design decisions (e.g., supporting ESM modules).
Before we delve into the code examples, let’s first set up a good test workflow.
Establishing a good testing workflow can greatly enhance productivity. By installing Vitest’s official extension for VS Code, you can streamline your testing process. It’s a good addition to Vitest’s CLI by providing a graphical interface for debugging tests as well as running and visualizing code coverage.
You can start debugging multiple test files or a single test by clicking on the play button with the bug icon:
You can further enhance your workflow by incorporating code coverage analysis. This provides insight into the effectiveness of your tests by indicating which parts of your codebase are covered by tests and which are not.
Before you start, you need to install a coverage provider. Depending on your preference, you can choose between the v8 or istanbul packages:
$ npm i -D @vitest/coverage-v8
Then, you have to configure the coverage provider in vitest.config.ts
:
// ... export default mergeConfig( viteConfig, defineConfig({ test: { // ... root: fileURLToPath(new URL("./", import.meta.url)), coverage: { provider: "v8", }, }, }), );
With your provider in place, you can run a test by clicking the run test with coverage button. The Test Explorer view provides a visual demonstration of this:
Alternatively, you can run tests with coverage from the terminal ($ vitest run --coverage
):
Another good way to work on your tests is to run the Vitest CLI in watch mode
:
$ vitest # watch mode is the default
By pressing the H
key, you can open the menu to run failed tests or all tests. Whenever you save a file, the containing tests are rerun:
Now, let’s look at our first test. All the examples in this article are taken from the companion GitHub project. Consider the following service function, fetchQuote
:
// quote.service.ts import type { Quote } from "./types/quote"; export async function fetchQuote() { const response = await fetch("https://dummyjson.com/quotes/random"); const data: Quote = await response.json(); return data; }
fetchQuote
performs a network call by using the global fetch
method. The returned fetch response is of type Quote
.
One approach is to create a spy to perform behavior verification after the test candidate, the fetchQuote
function, is invoked. The spy tests whether the global fetch call has been called with the correct parameters. In addition, we can perform a state verification of the response:
// quote.service.spy.test.ts import { describe, it, expect, vi } from "vitest"; import { fetchQuote } from "./quote.service"; describe("fetchQuote", () => { it("should return a valid quote", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); // invoke the test candidate const response = await fetchQuote(); // behavior verification expect(fetchSpy).toHaveBeenCalledWith( "https://dummyjson.com/quotes/random", ); // state verification expect(response.quote).toBeDefined(); }); });
As we’ve learned, a spy usually doesn’t alter the implementation, so a real network call is invoked. This isn’t ideal because it means that test runs are not deterministic. We can demonstrate this with a snapshot test by adding the following line:
// ... const response = await fetchQuote(); // ... expect(response).toMatchSnapshot();
If you run the test twice, you’ll get a snapshot mismatch because the response object has different contents:
In addition, we can use a rudimentary state verification because we can only test for the existence of the quote
object:
expect(response.quote).toBeDefined();
Let’s look at a better approach for this testing scenario by mocking the service:
// quote.service.mock.test.ts import { describe, it, expect, vi } from "vitest"; import { fetchQuote } from "./quote.service"; import type { Quote } from "./types/quote"; vi.mock("./quote.service"); describe("fetchQuote", () => { it("should return a valid quote", async () => { const dummyQuote: Quote = { id: 1, quote: "This is a dummy quote", author: "Anonymous", }; vi.mocked(fetchQuote).mockResolvedValue(dummyQuote); expect(await fetchQuote()).toEqual(dummyQuote); }); });
But wait — this isn’t a good test at all! It tests nothing because we replaced our test candidate with a mock. The test candidate has to be the unaltered original because we want to test its correct functionality. Let’s mock the network call and define what should be returned by global fetch
:
// quote.service.mock.test.ts import { describe, it, expect, vi } from "vitest"; import { fetchQuote } from "./quote.service"; import type { Quote } from "./types/quote"; describe("fetchQuote", () => { it("should return a valid quote", async () => { // just a dummy quote const dummyQuote: Quote = { id: 1, quote: "This is a dummy quote", author: "Anonymous", }; // needs to contain the required properties // https://developer.mozilla.org/en-US/docs/Web/API/Response const mockResponse = { ok: true, statusText: "OK", json: async () => dummyQuote, } as Response; // this time mock global fetch instead of spying on it globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); // state verification expect(await fetchQuote()).toEqual(dummyQuote); }); });
This is an example of a state verification by checking whether fetchQuote
‘s return value matches the response of the network call initiated by fetch
.
Dont worry — this test looks much more complicated than it is because we have to understand the signature of fetch
. As you can see in the documentation, it returns a promise that resolves to the Response
object representing the response to your request. This is the fetch
interface:
// node_modules/@types/node/globals.d.ts // ... function fetch( input: string | URL | globalThis.Request, init?: RequestInit, ): Promise<Response>; // ...
In our case, we defined a dummy object representing an object of type Quote
. We return this dummy in the JSON response when the promise gets resolved. Therefore, we need to use Vitest’s mockResolvedValue
, which is available for test doubles such as mocks and spies:
// ... const mockResponse = { // ... json: async () => dummyQuote, } as Response; globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); // ...
We can also refactor our previous test (quote.service.spy.test.ts
) in a way that the spy returns our fake response:
it("should return a valid quote by returning a fake response", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, statusText: "OK", json: async () => ({ quote: "Hello, World!" }), } as Response); const response = await fetchQuote(); expect(fetchSpy).toHaveBeenCalledWith( "https://dummyjson.com/quotes/random", ); expect(response.quote).toBe("Hello, World!"); });
Besides looking into the documentation or the globals’ types, we can also make use of debugging to find out the internals of external modules.
Testing the correct rendering of the App.vue
component is an example of white box testing:
<template> <h1>My awesome dashboard</h1> <img :src="imageUrl" :alt="imgAlt" /> <Counter /> <TodoFromStore /> <TodoFromComposable /> </template> <script setup lang="ts"> // hide imports const dashboardStore = useDashboardStore(); const imageUrl = ref(""); const imgAlt = ref(""); onMounted(async () => { const blob = await dashboardStore.createQuoteImageWithComposable(); if (blob) { imageUrl.value = URL.createObjectURL(blob); imgAlt.value = dashboardStore.shortenedQuote; } }); </script>
You will shortly find out that this component does not have a good design in terms of testability. This is how to test it:
// App.test.ts import { flushPromises, shallowMount } from "@vue/test-utils"; import { describe, expect, it, vi } from "vitest"; import App from "./App.vue"; import { createPinia, defineStore } from "pinia"; describe("App", () => { it("renders the image correctly", async () => { // mock URL.createObjectURL since it is an internal of the onMounted hook vi.stubGlobal("URL", { createObjectURL: () => "a nice URL" }); // Create a mock store const useMockDashboardStore = defineStore("dashboard", () => ({ createQuoteImageWithComposable: async () => { // Create a dummy blob const dummyBlob = new Blob(); return Promise.resolve(dummyBlob); }, shortenedQuote: "Dummy shortened quote", })); // init pinia and use the mock store const pinia = createPinia(); useMockDashboardStore(pinia); // shallow mount the App component // only the first (component tree) level of Vue components are rendered const wrapper = shallowMount(App); // make sure to invoke onMounted lifecycle hook and resolve the promise await flushPromises(); const imgEl = wrapper.find("img"); expect(imgEl.attributes().alt).toBe("Dummy shortened quote"); expect(imgEl.attributes().src).toBe("a nice URL"); // renders only first child level of App component: h1, img, tags of included Vue components expect(wrapper.html()).toMatchSnapshot(); }); });
The above code is complex, so let’s break it down. The first question is how to deal with Vue components. We will combine Vitest with Vue Test Utils, which is a library of helper functions to help users test their Vue components.
In this case, we can use either the mount
or shallowMount
functions to mount the Vue component. We’ll opt for the latter because it stubs out all children of the App
component.
Testing the App
component in isolation shouldn’t include implementation details of child components like Counter
. Without shallowMount
, we open up another can of worms because we also have to mock the dependencies of the child components.
Switching from shallowMount(App)
to mount(App)
breaks our test because of the child component internals:
We also need to set up a mock Pinia store (dashboard
) because the App
component accesses an action (createQuoteImageWithComposable
) and a getter (shortenedQuote
) in the onMounted
lifecycle Hook:
// Create a mock store const useMockDashboardStore = defineStore("dashboard", () => ({ createQuoteImageWithComposable: async () => { const dummyBlob = new Blob(); return Promise.resolve(dummyBlob); }, shortenedQuote: "Dummy shortened quote", })); const pinia = createPinia(); useMockDashboardStore(pinia); const wrapper = shallowMount(App);
Providing a mock store for testing doesn’t require using Vitest’s API functions — we can just create a store with defineStore
that exposes the API that App
‘s component uses.
Inside the onMounted
Hook, store.createQuoteImageWithComposable()
needs to return a promise that resolves to a blob response, while store.shortenedQuote
is just a string.
Due to an unfavorable implementation, we need to stub URL.createObjectURL
as it generates a URL by the returned blob:
vi.stubGlobal("URL", { createObjectURL: () => "a nice URL" })
In a minute, I’ll suggest how to improve the App
component to simplify this test.
Because this component relies on a lifecycle hook, we need to utilize another API function of Vue Test Utils (flushPromises
). It is important to wait with asserting values until all DOM updates are done and all pending promises are resolved:
await flushPromises();
This is why our test needs to be async
:
it("renders the image correctly", async () => { // ... });
Now we can make our assertions:
const imgEl = wrapper.find("img"); expect(imgEl.attributes().alt).toBe("Dummy shortened quote"); expect(imgEl.attributes().src).toBe("a nice URL"); // renders only first child level of App component: h1, img, tags of included Vue components expect(wrapper.html()).toMatchSnapshot();
Because we used shallowMount
, the snapshot looks like this:
// __snapshots__/App.test.ts.snap exports[`App > renders the image correctly 1`] = ` "<h1>My awesome dashboard</h1> <img src="a nice URL" alt="Dummy shortened quote"> <counter-stub></counter-stub> <todo-from-store-stub></todo-from-store-stub> <todo-from-composable-stub></todo-from-composable-stub>" `;
Let’s make our lives easier by refactoring the onMounted
Hook:
// see AppRefactored.vue onMounted(async () => { const image = await dashboardStore.createQuoteImageWithComposableRefactored(); if (image) { imageUrl.value = image.url; imgAlt.value = image.altText; } });
The store
function is also refactored to provide an image URL and alt text right away:
// return type of createQuoteImageWithComposableRefactored type Image = { url: string; altText: string };
The improved signature of the store
function makes the App
component easier to test because of better separation of concerns. The store method is completely in charge of providing the data to render. This means that we can get rid of stubbing out of URL.createObjectURL
because it was extracted into the store method.
If you place complex logic into store actions and invoke them from Vue components, you can test this logic in isolation. Let’s look at the example action used in the App
component:
// store.ts const createQuoteImageWithComposable = async () => { const blob: Ref<Blob | null> = ref(null); const jsonState = await useFetch<Quote>( "https://dummyjson.com/quotes/random", ); if (!jsonState.hasError) { currentQuote.value = jsonState.data; const blobState = await useFetch<Blob>( `https://dummyjson.com/image/768x80/008080/ffffff?text=${jsonState.data?.quote}`, { responseType: "blob" }, ); if (!blobState.hasError) { blob.value = blobState.data; } } return toValue(blob); };
This action makes two network calls with an imported module (useFetch
) to get two different responses. The challenge is to mock this imported useFetch
composable in a way that the first call returns JSON and the second one a blob response.
First, in order to mock the network calls in the arranging phase, we need to know the signature of the generic useFetch
composable. We will look at the test of useFetch
in a minute, but all we need to know right now is the signature:
interface State<T> { isLoading: boolean; hasError: boolean; error: Error | null; data: T | null; } // signature of useFetch function useFetch<T>(url: string, options?: { responseType?: "json" | "blob"; }): Promise<State<T>>
Our mock implementation of useFetch
can ignore the arguments but we need to make sure to receive the correct fetch state objects:
// store.test.ts import { useFetch } from "./composables/useFetch"; // ... vi.mock("./composables/useFetch"); // ... it("createQuoteImageWithComposable should create a quote image by calling useFetch twice", async () => { // arrange const dummyJsonState = { data: { id: 1, quote: "Hello, World!", author: "Anonymous" }, hasError: false, }; const dummyBlob = new Blob(); const mockedUseFetch = vi.mocked(useFetch) as Mock; mockedUseFetch .mockResolvedValueOnce(dummyJsonState) .mockResolvedValueOnce({ data: dummyBlob, hasError: false }); // act / call the test candidate const blob = await store.createQuoteImageWithComposable(); // state assertion expect(blob).toBe(dummyBlob); // behaviour assertions // check the arguments of the first useFetch call expect(mockedUseFetch.mock.calls\[0\][0]).toBe( "https://dummyjson.com/quotes/random", ); // check the arguments of the second useFetch call expect(mockedUseFetch.mock.calls\[1\][0]).toBe( `https://dummyjson.com/image/768x80/008080/ffffff?text=${dummyJsonState.data.quote}`, ); });
The comments inside of the previous snippet emphasize the different phases of a test: arrange (initialization of test doubles), act (invoking the test candidate), and assert (verification of correct behavior or result of test candidate).
The approach used in the following test is to mock the whole module:
vi.mock("./composables/useFetch")
vi.mock
gets hoisted to the top of the file, so the position in the test file does not matter. In addition, we need to provide the actual import:
import { useFetch } from "./composables/useFetch"`)
With that in place, we can create dummy return values that our mocked fetch calls should return:
// arrange the test const dummyJsonState = { data: { id: 1, quote: "Hello, World!", author: "Anonymous" }, hasError: false, }; const dummyBlob = new Blob(); const mockedUseFetch = vi.mocked(useFetch) as Mock; mockedUseFetch .mockResolvedValueOnce(dummyJsonState) .mockResolvedValueOnce({ data: dummyBlob, hasError: false });
With vi.mocked
, we get access to the mocked function and assign it to mockedUseFetch
. With the help of mockResolvedValueOnce
, we can resolve the first promise with a JSON state and the second promise with a blob state.
Finally, we can make our assertions:
// state assertion expect(blob).toBe(dummyBlob); // ... // check the arguments of the second useFetch call expect(mockedUseFetch.mock.calls\[1\][0]).toBe( `https://dummyjson.com/image/768x80/008080/ffffff?text=${dummyJsonState.data.quote}`, );
In the previous test, we mocked useFetch
because it constitutes an external dependency of the test candidate (store action createQuoteImageWithComposable
). Next, we’ll test useFetch
in isolation:
// useFetch.ts export async function useFetch<T>( url: string, options: { responseType?: "json" | "blob" } = { responseType: "json" }, ) { const fetchState = reactive<State<T>>({ isLoading: false, hasError: false, error: null, data: null, }); try { fetchState.isLoading = true; const response = await fetch(url); if (!response.ok) { throw new Error(response.statusText); } fetchState.data = options.responseType === "json" ? await response.json() : await response.blob(); } catch (err: unknown) { fetchState.hasError = true; fetchState.error = err as Error; // throw new Error(fetchState.error.message); } finally { fetchState.isLoading = false; } return fetchState; }
The challenge is to deal with the global fetch
function that makes actual network calls. For test stability and efficiency reasons, we do not want to perform actual fetch calls so, we’ll substitute it with a mock.
The corresponding test shows one approach to mock global fetch
, but there are multiple ways to do this as you will see in the cheat sheet section of this article. Let’s look at the happy path first, where useFetch
returns a JSON valid response, i.e., the network call is successful:
// useFetch.test.ts import { useFetch } from "./useFetch"; globalThis.fetch = vi.fn(); describe("useFetch", () => { it("should fetch data successfully and return the response", async () => { // arrange const dummyData = { message: "Hello, World!" }; const mockResponse = { ok: true, statusText: "OK", json: async () => dummyData, } as Response; vi.mocked(fetch).mockResolvedValue(mockResponse); // act const response = await useFetch("https://api.example.com/data"); // state assertions expect(response.isLoading).toBe(false); expect(response.hasError).toBe(false); expect(response.error).toBe(null); expect(response.data).toEqual(dummyData); // behavior assertions expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); expect(vi.mocked(fetch)).toHaveBeenCalledWith( "https://api.example.com/data", ); }); // ... });
We set the properties of the response mock object to represent a successful network call. Because we want to receive JSON data, we add a json
property to the mockResponse
object. We create a dummy data object that gets returned when the promise is resolved (dummyData
). Finally, we order Vitest to return our mockResponse
when a fetch call is made:
vi.mocked(fetch).mockResolvedValue(mockResponse);
In the acting phase, we will call the test candidate and store the mocked response in a variable:
const response = await useFetch("https://api.example.com/data");
Finally, we make state and behavior assertions and test whether the fetch state looks as expected.
Next is an example of testing a failing network call with the help of the mockRejectedValue
API function:
// useFetch.test.ts import { useFetch } from "./useFetch"; globalThis.fetch = vi.fn(); // ... it("should set the error state correctly when fetch request gets rejected", async () => { const errorMessage = "Network error"; vi.mocked(fetch).mockRejectedValue(new Error(errorMessage)); const response = await useFetch("https://api.example.com/data"); expect(response.isLoading).toBe(false); expect(response.hasError).toBe(true); expect(response.error!.message).toEqual(errorMessage); expect(response.data).toBe(null); });
As the last example in this chapter, we’ll examine a rather complex composable, useFetchTodoWithPolling
. The goal is to re-fetch to-dos at intervals after a defined number of milliseconds (parameter pollingInterval
):
// useFetchTodoWithPolling.ts import { ref, type Ref } from "vue"; import { useFetch } from "./useFetch"; interface Todo { id: number; todo: string; completed: boolean; userId: number; } export const useFetchTodoWithPolling = (pollingInterval: number) => { const todo: Ref<Todo | null> = ref(null); const doPoll = ref(true); const poll = async () => { try { if (doPoll.value) { const fetchState = await useFetch<Todo>( "https://dummyjson.com/todos/random", ); todo.value = fetchState.data; setTimeout(poll, pollingInterval); } } catch (err: unknown) { console.error(err); } }; poll(); const togglePolling = () => { doPoll.value = !doPoll.value; if (doPoll.value) { poll(); } }; return { todo, togglePolling, isPolling: doPoll }; };
The return
object contains the fetched to-dos and allows us to pause and continue polling:
// signature const useFetchTodoWithPolling: (pollingInterval: number) => { todo: Ref<Todo | null>; togglePolling: () => void; isPolling: Ref<boolean>; }
The following code snippets demonstrate a test that asserts the re-fetching functionality works as expected after five seconds and stops when togglePolling
is called:
// useFetchTodoWithPolling.test.ts describe("useFetchTodoWithPolling", () => { const todo = { id: 1, todo: "vitest", completed: false, userId: 1, }; vi.mock("./useFetch"); const useFetchMocked = vi.mocked(useFetch); beforeEach(() => { // clear the mock to avoid side effects and start count with 0 vi.clearAllMocks(); }); beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.useRealTimers(); }); it("should fetch the API every 5 seconds until polling is stopped", async () => { useFetchMocked .mockResolvedValueOnce({ isLoading: false, hasError: false, error: null, data: todo, }) .mockResolvedValueOnce({ isLoading: false, hasError: false, error: null, data: { ...todo, todo: "rules" }, }); const response = useFetchTodoWithPolling(5000); await flushPromises(); expect(response.todo.value?.todo).toEqual("vitest"); await vi.advanceTimersByTimeAsync(50); expect(response.todo.value?.todo).toEqual("vitest"); await vi.advanceTimersByTimeAsync(4970); expect(response.todo.value?.todo).toEqual("rules"); expect(useFetchMocked).toHaveBeenCalledTimes(2); response.togglePolling(); expect(useFetchMocked).toHaveBeenCalledTimes(2); });
Let’s discuss what the arrange and act phases have to look like. Regarding arranging the test setup, we need to mock useFetch
because this external module is used to fetch to-dos. We saw this in the previous section.
Another important aspect is how to handle the interval. Therefore, we utilize the Vitest API vi.useFakeTimers
to work with fake timers in tests. Because we need fake timers for all tests in this test file, we put the code in a beforeAll
Hook. It’s also good practice to reset to real timers after all tests of the test suite and not rely on implicit resetting:
beforeEach(() => { // clear the mock to avoid side effects and start the count with 0 for every test vi.clearAllMocks(); }); beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.useRealTimers(); });
Clearing all mocks in beforeEach
is also good practice to start every mock call count by 0
for every new test. In the end, tests are more semantic, easier to read, and less prone to errors.
Next, as part of the acting phase, we’ll invoke our test candidate:
const response = useFetchTodoWithPolling(5000);
The asserting phase is a bit more complex. Let’s break it down:
// the fetch function is called immediately await flushPromises(); expect(response.todo.value?.todo).toEqual("vitest"); // timer hasn't advanced enough yet await vi.advanceTimersByTimeAsync(50); expect(response.todo.value?.todo).toEqual("vitest"); // timer has advanced more than 5 seconds now await vi.advanceTimersByTimeAsync(4970); expect(response.todo.value?.todo).toEqual("rules"); expect(useFetchMocked).toHaveBeenCalledTimes(2); // stop polling response.togglePolling(); // no fetching should happen now expect(useFetchMocked).toHaveBeenCalledTimes(2);
flushPromises
is required because the composable makes a fetch call once before any timer interval starts. Then we utilize another Vitest utility with vi.advanceTimersByTimeAsync
. As you can see with the inline comments, now we establish different timer states and evaluate whether our fetch mock (useFetchMocked
) has been called or not.
Then we “act” again and stop polling (response.togglePolling()
). Afterward, we evaluate one last time that no more fetch calls have been done.
This section will provide examples of various Vitest use cases that you can use for future reference. Unlike the detailed discussion above, the focus of this section is to show how to use Vitest’s API through simplified code snippets.
Testing problems often have multiple solutions. The following examples will cover specific scenarios, such as mocking default imports, which can be challenging, especially for new developers. However, experienced developers familiar with other testing libraries like Jest will also find this compilation useful.
The following examples demonstrate how to create test doubles of modules exposed as default exports:
// default-func.ts const getWithEmoji = (message: string) => { return `${message} 😎`; }; export default getWithEmoji; // default-obj.ts export default { getWithEmoji: (message: string) => { return `${message} 😎`; }, }; // default-import.spec.ts import { type Mock } from "vitest"; import getWithEmojiFunc from "./default-func"; import getWithEmojiObj from "./default-obj"; it("mock default function", () => { vi.mock("./default-func", () => { return { default: vi.fn((message: string) => `${message} 🥳`), }; }); expect(getWithEmojiFunc("hello world")).toEqual("hello world 🥳"); }); it("spy on default object's method", () => { const getWithEmojiSpy = vi.spyOn(getWithEmojiObj, "getWithEmoji") as Mock; const result = getWithEmojiSpy("spy kids"); expect(result).toEqual("spy kids 😎"); expect(getWithEmojiSpy).toHaveBeenCalledWith("spy kids"); });
The following shows how to create spies of a function exposed as a default import:
// spy-default-func.spec.ts import { type Mock } from "vitest"; import * as exports from "./default-func"; it("spy on default function", () => { const getWithEmojiSpy = vi.spyOn(exports, "default") as Mock; const result = getWithEmojiSpy("spy kids"); expect(result).toEqual("spy kids 😎"); expect(getWithEmojiSpy).toHaveBeenCalledWith("spy kids"); });
The next example shows how to create a spy of a function and a property, both exposed as named imports:
// named-import-property.ts export const magicNumber: number = 42; // named-import-property.spec.ts import * as exports from "./named-import-property"; it("mock property", () => { vi.spyOn(exports, "magicNumber", "get").mockReturnValue(41); expect(exports.magicNumber).toBe(41); });
This next example demonstrates how to mock named imports:
// named-import-func.ts export const getWithEmoji = (message: string) => { return `${message} 😎`; }; // named-import-func.spec.ts import { getWithEmoji } from "./named-import-func"; it("mock named import (function)", () => { vi.mock("./named-import-func"); const dummyMessage = "Hello, world!"; const mockGetWithEmji = vi .mocked(getWithEmoji) .mockReturnValue(`${dummyMessage} 🤩`); const result = mockGetWithEmji(dummyMessage); expect(result).toBe(`${dummyMessage} 🤩`); });
These examples demonstrate how to mock imported class modules (named and default imports):
// default-class.ts export default class Bike { ride() { return "original value"; } } // named-class.ts export class Car { drive() { return "original value"; } } // class.spec.ts import Bike from "./default-class"; import { Car } from "./named-class"; test("mock a method of a default import class", () => { vi.mock("./default-class", () => { const MyClass = vi.fn(); MyClass.prototype.ride = vi.fn(); return { default: MyClass }; }); const myMethodMock = vi.mocked(Bike.prototype.ride); myMethodMock .mockReturnValueOnce("mocked value") .mockReturnValueOnce("another mocked value"); const myInstance = new Bike(); let result = myInstance.ride(); expect(result).toBe("mocked value"); result = Bike.prototype.ride(); expect(result).toBe("another mocked value"); expect(myMethodMock).toHaveBeenCalledTimes(2); }); test("mock a method of a named export class", () => { vi.mock("./named-class", () => { const MyClass = vi.fn(); MyClass.prototype.drive = vi.fn(); return { Car: MyClass }; }); const myMethodMock = vi.mocked(Car.prototype.drive); myMethodMock .mockReturnValueOnce("mocked value") .mockReturnValueOnce("another mocked value"); const myInstance = new Car(); let result = myInstance.drive(); expect(result).toBe("mocked value"); result = Car.prototype.drive(); expect(result).toBe("another mocked value"); expect(myMethodMock).toHaveBeenCalledTimes(2); });
Snapshot testing can be used for data objects. When a snapshot mismatch occurs and causes a test to fail, and if the mismatch is expected, you can press the U
key to update the snapshot once:
// data-snapshot.spec.ts test("snapshot testing", () => { const data = { id: 1, name: "John Doe", email: "[email protected]", }; expect(data).toMatchSnapshot(); }); // Vitest snapshot example with an object like above but also a mocked function test("snapshot testing with a mocked function", () => { const person = { id: 1, name: "John Doe", email: "[email protected]", contact: vi.fn(), }; expect(person).toMatchSnapshot(); });
This example shows how to use snapshot testing to record the render output of Vue components. Consider the following component:
<!-- AwesomeComponent.vue --> <template> <h1 id="awesome-component">{{ greeting }}</h1> </template> <script setup lang="ts"> import { computed } from "vue"; const props = defineProps<{ name: string }>(); const greeting = computed(() => "Hello, " + props.name); </script> <style scoped> #awesome-component { color: red; } </style>
The following demonstrates how to use Vue Test Utils to initialize the Vue component with a prop and create an HTML snapshot:
// component-snapshot.spec.ts import { mount } from "@vue/test-utils"; import AwesomeComponent from "./AwesomeComponent.vue"; test("renders component correctly", () => { const wrapper = mount(AwesomeComponent, { props: { name: "reader", }, }); expect(wrapper.html()).toMatchSnapshot(); });
This example demonstrates how to test composables using the Composition API. To trigger an effect (because of watchEffect
), you can make use of Vue’s nextTick
API:
// composition-api.spec.ts import { ref, watchEffect, nextTick } from "vue"; export function useCounter() { const count = ref(2); watchEffect(() => { if (count.value > 5) { count.value = 0; } }); const increment = () => { count.value++; }; return { count, increment, }; } test("useCounter", async () => { const { count, increment } = useCounter(); expect(count.value).toBe(2); increment(); expect(count.value).toBe(3); increment(); expect(count.value).toBe(4); increment(); expect(count.value).toBe(5); increment(); expect(count.value).toBe(6); // wait for the watcher to update the count await nextTick(); expect(count.value).toBe(0); });
The following example shows a composable (useCounter
) using the Composition API (ref
, watchEffect
) and a lifecycle hook (onMounted
). The Vue component utilizes the composable’s interface to update a counter on button clicks:
// useCounter.ts import { onMounted, ref, watchEffect } from "vue"; export function useCounter() { const count = ref(0); onMounted(() => { count.value = 2; }); watchEffect(() => { if (count.value > 3) { count.value = 0; } }); const increment = () => { count.value++; }; return { count, increment, }; }
<!-- Counter.vue --> <template> <div>count: {{ count }}</div> <button @click="increment">Increment</button> </template> <script setup lang="ts"> import { useCounter } from "./useCounter"; const { count, increment } = useCounter(); </script>
The following snippet shows how to mount a component, fire button click events, and verify that the used composable works as expected:
// Counter.spec.ts import { mount } from "@vue/test-utils"; import Counter from "./Counter.vue"; import { nextTick } from "vue"; test("renders component correctly", async () => { const wrapper = mount(Counter); const div = wrapper.find("div"); expect(div.text()).toContain("count: 0"); // make sure onMounted() is called by waiting until the next DOM update await nextTick(); expect(div.text()).toContain("count: 2"); const button = wrapper.find("button"); button.trigger("click"); button.trigger("click"); await nextTick(); expect(div.text()).toContain("count: 0"); });
The next example demonstrates how to mock only the relevant parts of an external module required for a test scenario (getWithEmoji
function of stringOperations
module). The other function (log
) is irrelevant. Verification of the mock reveals that log
is therefore not defined:
// partly-mock-module.ts const getWithEmoji = (message: string) => { return `${message} 😎`; }; export const stringOperations = { log: (message: string) => console.log(message), getWithEmoji, }; // partly-mock-module.spec.ts import { stringOperations } from "./partly-mock-module"; it("mock method of imported object", () => { vi.mock("./partly-mock-module", () => { return { stringOperations: { getWithEmoji: vi.fn().mockReturnValue("Hello world 🤩"), }, }; }); const mockGetWithEmoji = vi.mocked(stringOperations.getWithEmoji); const result = mockGetWithEmoji("Hello world"); expect(mockGetWithEmoji).toHaveBeenCalledWith("Hello world"); expect(result).toEqual("Hello world 🤩"); expect(vi.isMockFunction(mockGetWithEmoji)).toBe(true); expect(stringOperations.log).not.toBeDefined(); });
This is an alternative variant providing both functions of stringOperations
. The function getWithEmoji
gets replaced by a mock implementation and log
is kept in its original form:
// partly-mock-module-restore-original.spec.ts import { stringOperations } from "./partly-mock-module"; it("mock method of imported object and restore other original properties", () => { vi.mock("./partly-mock-module", async (importOriginal) => { const original = (await importOriginal()) as typeof import("./partly-mock-module"); return { stringOperations: { log: original.stringOperations.log, getWithEmoji: vi.fn().mockReturnValue("Hello world 🤩"), }, }; }); const { getWithEmoji: mockGetWithEmoji, log } = vi.mocked(stringOperations); const result = mockGetWithEmoji("Hello world"); expect(mockGetWithEmoji).toHaveBeenCalledWith("Hello world"); expect(result).toEqual("Hello world 🤩"); expect(vi.isMockFunction(mockGetWithEmoji)).toBe(true); expect(log).toBeDefined(); expect(vi.isMockFunction(log)).toBe(false); });
In contrast to vi.mock
, vi.doMock
isn’t hoisted to the top of a file. It’s useful when you need to incorporate external variables inside mock implementations:
// someModule.ts export function someFunction() { return "original implementation"; } // doMock.spec.ts import { someFunction } from "./someModule"; it("original module", () => { const result = someFunction(); expect(result).toEqual("original implementation"); }); it("doMock allows to use variables from scope", async () => { const dummyText = "dummy text"; // vi.mock does not allow to use variables from the scope. It leads to errors like: // Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. // vi.doMock does not get hoisted to the top instead of vi.mock vi.doMock("./someModule", () => { return { someFunction: vi.fn().mockReturnValue(dummyText), }; }); // dynamic import is required to get the mocked module with vi.docMock const { someFunction: someFunctionMock } = await import("./someModule"); const result = someFunctionMock(); expect(someFunctionMock).toHaveBeenCalled(); expect(result).toEqual(dummyText); });
The following examples highlight how to use mockClear
, mockReset
, and mockRestore
to clean up your tests, especially by testing doubles to avoid side effects:
// add.ts export const add = (a, b) => a + b; // cleanup-mocks.spec.ts import { add } from "./add"; test("mockClear", () => { const mockFunc = vi.fn(); mockFunc(); expect(mockFunc).toHaveBeenCalledTimes(1); // resets call history mockFunc.mockClear(); mockFunc(); expect(mockFunc).toHaveBeenCalledTimes(1); }); test("mockReset vs mockRestore", async () => { const mockAdd = vi.fn(add).mockImplementation((a, b) => 2 * a + 2 * b); expect(vi.isMockFunction(mockAdd)).toBe(true); expect(mockAdd(1, 1)).toBe(4); expect(mockAdd).toHaveBeenCalledTimes(1); // resets call history and mock function returns undefined mockAdd.mockReset(); expect(vi.isMockFunction(mockAdd)).toBe(true); expect(mockAdd(1, 1)).toBeUndefined(); expect(mockAdd).toHaveBeenCalledTimes(1); // resets call history and mock function restores implementation to add mockAdd.mockRestore(); expect(vi.isMockFunction(mockAdd)).toBe(true); expect(mockAdd(1, 1)).toBe(2); // original implementation expect(mockAdd).toHaveBeenCalledTimes(1); });
Storing test doubles in a dedicated folder (__mocks__
) allows for automatic mocking. The following example shows how to mock axios
globally:
// <root-folder>/__mocks__/axios.ts import { vi } from "vitest"; const mockAxios = { get: vi.fn((url: string) => Promise.resolve({ data: { urlCharCount: url.length } }), ), post: vi.fn(() => Promise.resolve({ data: {} })), // Add other methods as needed }; export default mockAxios; // axios.auto-mocking.spec.ts import axios from "axios"; vi.mock("axios"); // auto-mocking example with <root-folder>/__mocks__ folder test("mocked axios", async () => { const response = await axios.get("url"); expect(response.data.urlCharCount).toBe(3); expect(axios.get).toHaveBeenCalledWith("url"); expect(axios.delete).toBeUndefined(); expect(vi.isMockFunction(axios.get)).toBe(true); expect(vi.isMockFunction(axios.post)).toBe(true); expect(vi.isMockFunction(axios.delete)).toBe(false); }); // use actual axios in test test("can get actual axios", async () => { const ax = await vi.importActual<typeof axios>("axios"); expect(vi.isMockFunction(ax.get)).toBe(false); });
The following example demonstrates how you can mock axios
only for individual tests:
// axios.spec.ts import axios from "axios"; test("mocked axios", async () => { const { default: ax } = await vi.importMock<typeof import("axios")>("axios"); const response = await ax.get("url"); expect(ax.get).toHaveBeenCalledWith("url"); expect(response.data.urlCharCount).toEqual(3); }); test("actual axios is not mocked", async () => { expect(vi.isMockFunction(axios.get)).toBe(false); });
Vitest provides the vi.setSystemTime
method to fake the system time:
// date-and-time.spec.ts const getCurrentTime = () => new Date().toTimeString().slice(0, 5); it("should return the correct system time", () => { vi.setSystemTime(new Date("2024-04-04 15:17")); expect(getCurrentTime()).toBe("15:17"); // cleanup vi.useRealTimers(); });
The following examples show how to verify thrown exceptions:
// error.spec.ts test("should throw an error", () => { expect(() => { throw new Error("Error message"); }).toThrow("Error message"); }); test("should throw an error after rejected fetch", async () => { const errorMessage = "Network error"; vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error(errorMessage))), ); await expect(fetch("https://api.example.com/data")).rejects.toThrow( "Network error", ); }); test("more sophisticated example of expecting error and resolved value", async () => { const errorMessage = "Network error"; class MyError extends Error { constructor(message: string) { super(message); this.name = "MyError"; } } globalThis.fetch = vi.fn().mockRejectedValue(new MyError(errorMessage)); await expect(fetch("https://api.example.com/data")).rejects.toThrowError( "Network error", ); await expect(fetch("https://api.example.com/data")).rejects.toThrowError( Error, ); await expect(fetch("https://api.example.com/data")).rejects.toThrowError( /Network/, ); globalThis.fetch = vi.fn().mockResolvedValue("success"); const response = await fetch("https://api.example.com/data"); expect(response).toBe("success"); });
The following shows different methods for replacing global fetch
with test doubles:
// fetch.spec.ts test("variant with globalThis.fetch", async () => { const dummyData = { message: "hey" }; const mockResponse = { ok: true, statusText: "OK", json: async () => dummyData, } as Response; globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); const response = await fetch("https://api.example.com/data"); const data = await response.json(); expect(data).toEqual(dummyData); }); test("variant with globalThis.fetch and vi.mocked", async () => { const dummyBlob = new Blob(); const mockResponse = { ok: true, statusText: "OK", blob: async () => dummyBlob, } as Response; globalThis.fetch = vi.fn(); vi.mocked(fetch).mockResolvedValue(mockResponse); const response = await fetch("https://api.example.com/data"); const data = await response.blob(); expect(response.blob).toBeDefined(); expect(data).toEqual(dummyBlob); expect(response.json).not.toBeDefined(); }); test("variant with vi.stubGlobal", async () => { const dummyData = { data: "hey" }; const dummyBlob = new Blob(); vi.stubGlobal( "fetch", vi.fn(() => Promise.resolve({ blob: async () => dummyBlob, json: () => dummyData, }), ), ); const response = await fetch("https://api.example.com/data"); const data = await response.json(); const blob = await response.blob(); expect(response).toEqual({ json: expect.any(Function), blob: expect.any(Function), }); expect(data).toEqual(dummyData); expect(blob).toEqual(dummyBlob); }); test.todo("variant with rejected fetch", async () => { // see error.spec.ts });
With Vitest’s test.each
API, you can pass a different set of data into tests:
// run tests 3x with different string values const inputs = ["Hello", "world", "!"]; test.each(inputs)("Testing string length", (input) => { expect(input.length).toBeGreaterThan(0); }); test.each(inputs)("Testing string lengths of %s", (input) => { expect(input.length).toBeGreaterThan(0); });
globalThis
and window
with stubGlobal
vi.stubGlobal
can be used to mock different global properties such as console.log
or fetch
. To stub window
properties, you need to use jsdom
or happy-dom
:
test("Math example", () => { vi.stubGlobal("Math", { random: () => 0.5 }); const result = Math.random(); expect(result).toBe(0.5); }); test("Date example", () => { vi.stubGlobal( "Date", class { getTime() { return 1000; } }, ); expect(new Date().getTime()).toBe(1000); expect(new Date().getTime()).not.toBe(2000); }); test("console example", () => { vi.stubGlobal("console", { log: vi.fn(), error: vi.fn(), }); console.log("Hello, World!"); console.error("An error occurred!"); const log = vi.mocked(console.log); const error = vi.mocked(console.error); expect(log).toHaveBeenCalledWith("Hello, World!"); expect(error).toHaveBeenCalledWith("An error occurred!"); expect(vi.isMockFunction(log)).toBe(true); expect(vi.isMockFunction(error)).toBe(true); }); test("window example", () => { vi.stubGlobal("window", { innerWidth: 1024, innerHeight: 768, }); expect(window.innerWidth).toBe(1024); expect(window.innerHeight).toBe(768); }); test.todo("fetch example", () => { // see fetch.spec.ts });
Vitest features many useful API functions to verify invocations of test doubles:
test("verify return value of a mocked function", () => { const mockFn = vi.fn(); // Set the return value of the mock function mockFn.mockReturnValue("mocked value"); // Call the mock function const result = mockFn(); // Verify that the return value has a particular value expect(result).toBe("mocked value"); // Verify that the return value is of a particular type expect(typeof result).toBe("string"); }); test("verify invocations of a mocked function with any argument", () => { const mockFn = vi.fn(); // Call the mock function with different arguments mockFn("arg1", 123); mockFn("arg2", { key: "value" }); // Verify that the mock function was called with any string as the first argument expect(mockFn).toHaveBeenCalledWith(expect.any(String), expect.anything()); // Verify that the mock function was called with any number as the second argument expect(mockFn).toHaveBeenCalledWith(expect.anything(), expect.any(Number)); // Verify that the mock function was called with any object as the second argument expect(mockFn).toHaveBeenCalledWith(expect.anything(), expect.any(Object)); }); test("verify invocations of a mocked function with calls array", () => { const mockFn = vi.fn(); // Call the mock function with different arguments mockFn("arg1", "arg2"); mockFn("arg3", "arg4"); // Verify that the mock function was called expect(mockFn).toHaveBeenCalled(); // Verify that the mock function was called exactly twice expect(mockFn).toHaveBeenCalledTimes(2); // Verify that the mock function was called with specific arguments expect(mockFn).toHaveBeenCalledWith("arg1", "arg2"); expect(mockFn).toHaveBeenCalledWith("arg3", "arg4"); // Verify the order of calls and their arguments using the mock array expect(mockFn.mock.calls[0]).toEqual(["arg1", "arg2"]); expect(mockFn.mock.calls[1]).toEqual(["arg3", "arg4"]); // clear the mock call history mockFn.mockClear(); // Verify that the mock function was not called after resetting expect(mockFn).not.toHaveBeenCalled(); // Call the mock function again with different arguments mockFn("arg5", "arg6"); // Verify that the mock function was called once after resetting expect(mockFn).toHaveBeenCalledTimes(1); // Verify that the mock function was called with specific arguments after resetting expect(mockFn).toHaveBeenCalledWith("arg5", "arg6"); // Verify the order of calls and their arguments using the mock array after resetting expect(mockFn.mock.calls[0]).toEqual(["arg5", "arg6"]); }); test("verify invocations of a mocked function with a specific object", () => { interface MyInterface { key: string; } const mockFn = vi.fn(); // Call the mock function with an object that matches the MyInterface interface mockFn({ key: "value", extraProp: "extra" }); // Verify that the mock function was called with an object containing a specific key-value pair expect(mockFn).toHaveBeenCalledWith( expect.objectContaining({ key: "value" } as MyInterface), ); });
While testing may require an upfront investment of time and effort, the long-term benefits it provides in terms of code quality, maintainability, and developer understanding make it an invaluable practice in software development. This is why you should invest your time in learning Vitest to improve the quality of your JavaScript project.
Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If you’re interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens in your Vue apps, including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.
The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error and what state the application was in when an issue occurred.
Modernize how you debug your Vue apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.