EthicsComputerCourses
Join training +91 90532 10052

Testing with Vitest

Vue.js · Lesson 33 of 41 Advanced 8 min read Updated

Every time you change code, something that used to work might break. Clicking through the whole app by hand after each change is slow and you will miss things. Automated tests are small programs that check your code for you in seconds, every time.

In this lesson you will set up Vitest (a fast test runner built on Vite) with Vue Test Utils (the official library for mounting Vue components in tests), test plain functions, mount components, simulate clicks and typing, check emitted events, test a Pinia store and replace fetch with a fake.

Kinds of tests

KindWhat it checksTool
Unit testOne function, composable or store on its ownVitest
Component testOne component: what it renders and how it reacts to clicks and propsVitest + Vue Test Utils
End-to-end (E2E) testThe whole app in a real browser, like a userPlaywright or Cypress

Most projects have many unit and component tests, which are fast, and a few end-to-end tests for the most important journeys such as login and checkout.

Setting up

Tick Vitest when you run npm create vue@latest (or pass --vitest). create-vue installs vitest, @vue/test-utils and jsdom (a simulated browser DOM that runs in Node.js), adds a vitest.config.js, and adds this script:

Terminal
npm run test:unit

This starts Vitest in watch mode: it runs the tests, then re-runs the affected ones whenever you save a file. To run everything once, for example on a build server, use npx vitest run. Test files end in .spec.js or .test.js, and create-vue puts them in __tests__ folders next to the code.

Your first unit test

Start with a plain function. This one adds GST and formats rupees:

JavaScript
// src/utils/price.js
export function withGst(amount, rate = 18) {
  return Math.round(amount * (1 + rate / 100));
}

export function formatRupees(amount) {
  return "₹" + amount.toLocaleString("en-IN");
}
JavaScript
// src/utils/__tests__/price.spec.js
import { describe, it, expect } from "vitest";
import { withGst, formatRupees } from "../price.js";

describe("withGst", () => {
  it("adds 18% GST by default", () => {
    expect(withGst(1000)).toBe(1180);
  });

  it("accepts another rate", () => {
    expect(withGst(1000, 5)).toBe(1050);
  });
});

describe("formatRupees", () => {
  it("uses Indian digit grouping", () => {
    expect(formatRupees(129999)).toBe("₹1,29,999");
  });
});
  • describe groups related tests, and it (or test) defines one test with a readable sentence.
  • expect(value) starts an assertion. A matcher such as toBe() (strict equality), toEqual() (same contents), toContain() or toHaveLength() says what you expect.

Testing a component

mount() from Vue Test Utils renders a component into the fake DOM and returns a wrapper with helpers to inspect and interact with it:

HTML
<!-- src/components/ChaiCounter.vue -->
<script setup>
import { ref, computed } from "vue";
const cups = ref(0);
const bill = computed(() => cups.value * 15);
</script>

<template>
  <p>Cups: {{ cups }}, bill: ₹{{ bill }}</p>
  <button @click="cups++">Add cup</button>
</template>
JavaScript
// src/components/__tests__/ChaiCounter.spec.js
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import ChaiCounter from "../ChaiCounter.vue";

describe("ChaiCounter", () => {
  it("starts at zero", () => {
    const wrapper = mount(ChaiCounter);
    expect(wrapper.text()).toContain("Cups: 0, bill: ₹0");
  });

  it("adds a cup and updates the bill when clicked", async () => {
    const wrapper = mount(ChaiCounter);
    await wrapper.find("button").trigger("click");
    await wrapper.find("button").trigger("click");
    expect(wrapper.text()).toContain("Cups: 2, bill: ₹30");
  });
});

trigger("click") returns a promise that resolves after Vue has updated the DOM, so always await it. Without await, you would check the text before the re-render and the test would fail.

The idea behind mount() is simple: create the app on a detached element, interact, wait for Vue, and check. This browser-only demo does the same steps by hand, without any test library, so you can see them run:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Testing idea</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@3.5.44/dist/vue.global.prod.js"></script>
  <style>.pass { color: #2f855a; } .fail { color: #c53030; }</style>
</head>
<body>
  <h3>Mini test run</h3>
  <ul id="results"></ul>

  <script>
    const { createApp, ref, computed, nextTick } = Vue;

    const ChaiCounter = {
      setup() {
        const cups = ref(0);
        const bill = computed(() => cups.value * 15);
        return { cups, bill };
      },
      template: `<p>Cups: {{ cups }}, bill: ₹{{ bill }}</p><button @click="cups++">Add cup</button>`
    };

    function report(name, ok) {
      const li = document.createElement("li");
      li.className = ok ? "pass" : "fail";
      li.textContent = (ok ? "PASS " : "FAIL ") + name;
      document.querySelector("#results").append(li);
    }

    async function run() {
      // 1. "mount": render into an element that is not on the page
      const root = document.createElement("div");
      createApp(ChaiCounter).mount(root);
      report("starts at zero", root.textContent.includes("Cups: 0, bill: ₹0"));

      // 2. "trigger": click twice, then wait for Vue to update the DOM
      root.querySelector("button").click();
      root.querySelector("button").click();
      await nextTick();
      report("two clicks give a bill of ₹30", root.textContent.includes("Cups: 2, bill: ₹30"));

      // 3. a deliberately wrong expectation, to see a failure
      report("bill is ₹45 after two clicks (wrong on purpose)", root.textContent.includes("₹45"));
    }

    run();
  </script>
</body>
</html>

Props, typing and emitted events

Pass props with the second argument of mount(). Find elements with CSS selectors; a data-testid attribute gives tests a stable hook that does not change when you restyle the component. wrapper.emitted() records every event the component emitted. These tests use the QtyStepper from Component Events & v-model, with data-testid="plus" and data-testid="minus" added to its buttons:

JavaScript
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import QtyStepper from "../QtyStepper.vue";

describe("QtyStepper", () => {
  it("emits the next value when + is clicked", async () => {
    const wrapper = mount(QtyStepper, { props: { modelValue: 3 } });
    await wrapper.get('[data-testid="plus"]').trigger("click");
    expect(wrapper.emitted("update:modelValue")).toEqual([[4]]);
  });

  it("never goes above max", async () => {
    const wrapper = mount(QtyStepper, { props: { modelValue: 4, max: 4 } });
    await wrapper.get('[data-testid="plus"]').trigger("click");
    expect(wrapper.emitted("update:modelValue")[0]).toEqual([4]);
  });
});

emitted("name") is an array with one entry per emit, and each entry is an array of the arguments, hence [[4]]. For inputs, await wrapper.get("input").setValue("Pune") types a value and fires the right event. find() returns an empty wrapper when nothing matches, while get() throws a clear error, so prefer get() when the element must exist.

When a test fails, Vitest shows what it expected, what it received and the line:

Output
 FAIL  src/components/__tests__/QtyStepper.spec.js > QtyStepper > emits the next value when + is clicked
AssertionError: expected [ [ 4 ] ] to deeply equal [ [ 5 ] ]

 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)

Testing stores and faking fetch

A Pinia store can be tested without any component. Give each test a fresh Pinia so tests do not share state:

JavaScript
import { describe, it, expect, beforeEach } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import { useCartStore } from "../cart.js";

describe("cart store", () => {
  beforeEach(() => {
    setActivePinia(createPinia());
  });

  it("increases quantity for a product already in the cart", () => {
    const cart = useCartStore();
    const tea = { id: 1, name: "Darjeeling tea", price: 320 };
    cart.add(tea);
    cart.add(tea);
    expect(cart.items[0].qty).toBe(2);
    expect(cart.total).toBe(640);
  });
});

Tests must not depend on a real network: it is slow, and the data can change. Replace fetch with a mock, a fake function you control, using vi.fn() and vi.stubGlobal(). After mounting, flushPromises() waits for the pending promises to finish:

JavaScript
import { it, expect, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import UserName from "../UserName.vue";   // loads /users/1 in onMounted and shows the name

afterEach(() => vi.unstubAllGlobals());

it("shows the name from the API", async () => {
  const fakeFetch = vi.fn().mockResolvedValue({
    ok: true,
    json: () => Promise.resolve({ name: "Priya Nair" })
  });
  vi.stubGlobal("fetch", fakeFetch);

  const wrapper = mount(UserName);
  expect(wrapper.text()).toBe("Loading...");
  await flushPromises();
  expect(wrapper.text()).toBe("Priya Nair");
  expect(fakeFetch).toHaveBeenCalledTimes(1);
});

For components that use stores, the @pinia/testing package provides createTestingPinia(), which you pass to mount(Comp, { global: { plugins: [createTestingPinia()] } }) to control the store's starting state.

Common mistakes

  • Forgetting await on trigger() and setValue(). The DOM has not updated yet when you check it.
  • Tests that share state. Create a fresh Pinia and remount components in each test.
  • Real network calls in tests. Mock fetch so tests are fast and repeatable.
  • Testing implementation details. Check rendered text, emitted events and visible behaviour.
  • Fragile selectors. Selecting by CSS classes used for styling breaks on redesign; use data-testid or text.

Key points

  • Vitest runs tests; Vue Test Utils mounts components; create-vue sets both up with npm run test:unit.
  • Tests use describe, it and expect(...).toBe/toEqual/toContain(...).
  • mount(Component, { props }) returns a wrapper with text(), get(), find(), trigger(), setValue() and emitted().
  • Always await interactions; use flushPromises() after async work.
  • Test stores with setActivePinia(createPinia()), and mock fetch with vi.fn() and vi.stubGlobal().

Exercise

Write a test file for a LoginForm.vue component that has an email input, a password input and a "Log in" button, and emits login with { email, password } when the form is submitted, but only when both fields are filled. Write two tests: one where both fields are filled and the event is emitted with the right data, and one where the password is empty and no event is emitted.

Show solution
JavaScript
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import LoginForm from "../LoginForm.vue";

describe("LoginForm", () => {
  it("emits login with the typed values", async () => {
    const wrapper = mount(LoginForm);
    await wrapper.get('input[type="email"]').setValue("meera@example.com");
    await wrapper.get('input[type="password"]').setValue("chai@123");
    await wrapper.get("form").trigger("submit");
    expect(wrapper.emitted("login")).toEqual([[{ email: "meera@example.com", password: "chai@123" }]]);
  });

  it("does not emit when the password is empty", async () => {
    const wrapper = mount(LoginForm);
    await wrapper.get('input[type="email"]').setValue("meera@example.com");
    await wrapper.get("form").trigger("submit");
    expect(wrapper.emitted("login")).toBeUndefined();
  });
});

setValue() fills each input and fires its input event, and triggering submit on the form runs the component's @submit.prevent handler. emitted("login") is undefined when the event was never emitted.

Quick Quiz

1. Which library mounts Vue components inside tests?

2. Why do you write await wrapper.find("button").trigger("click")?

3. What does wrapper.emitted("update:modelValue") return after one emit of 4?

4. Why mock fetch in component tests?

Learn Vue.js with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call