Vue – Testing with Vitest and Vue Test Utils

December 23, 20256 min readUpdated 8/24/2026

Two kinds of test, and they are good at different things. Unit tests mount one component in isolation and are fast enough to run on every save. End-to-end tests drive a real browser against a real backend and catch the things that only break when the pieces are connected.

A note on this lesson's snippets. The demo application's own suite is Playwright end-to-end — it has no unit tests. So the Playwright code below is quoted from it and is real; the Vitest and Vue Test Utils examples are written for this lesson. They are correct, but unlike the rest of this track they are not lifted from a running codebase, and it is better to say so than to pretend otherwise.

Vitest

Vitest is the test runner for Vite projects. It reuses your vite.config.js, so aliases, plugins and environment variables all work in tests without a second configuration to keep in sync — the main reason it displaced Jest in this ecosystem. The API is Jest's, so most Jest knowledge transfers directly.

npm install -D vitest @vue/test-utils jsdom
// vite.config.js
export default defineConfig({
  plugins: [vue()],
  test: {
    // Components need a DOM. Without this you get "document is not defined".
    environment: "jsdom",
    globals: true,
  },
});
"scripts": {
  "test:unit": "vitest",
  "test:unit:run": "vitest run"
}

vitest watches; vitest run runs once and exits, which is what CI wants.

Mounting a component

import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import StatusBadge from "../src/components/ui/StatusBadge.vue";

describe("StatusBadge", () => {
  it("renders the label and variant for a known status", () => {
    const wrapper = mount(StatusBadge, {
      props: { status: "PUBLISHED" },
    });

    expect(wrapper.text()).toContain("Published");
    expect(wrapper.classes()).toContain("text-bg-success");
  });

  it("falls back to the raw status when it is unrecognised", () => {
    const wrapper = mount(StatusBadge, { props: { status: "WEIRD" } });

    expect(wrapper.text()).toContain("WEIRD");
    expect(wrapper.classes()).toContain("text-bg-secondary");
  });
});

Assert on rendered output, not internals. wrapper.text() and wrapper.classes() test what a user sees. Reaching into wrapper.vm.someRef tests how it is built, and turns every refactor into a test failure.

The second test is the one worth writing. Anyone can assert the happy path; the fallback branch is where the bug will be.

Props in, events out

A component's contract is its props and its emits, so that is what to test:

import { mount } from "@vue/test-utils";
import TagInput from "../src/components/admin/TagInput.vue";

it("slugifies a tag and emits it without mutating the prop", async () => {
  const tags = ["dunk"];
  const wrapper = mount(TagInput, { props: { modelValue: tags } });

  await wrapper.find("input").setValue("Buzzer Beater");
  await wrapper.find("input").trigger("keydown.enter");

  // The emitted value is a NEW array containing the slugified tag...
  expect(wrapper.emitted("update:modelValue")[0]).toEqual([["dunk", "buzzer-beater"]]);
  // ...and the prop the parent owns was not touched.
  expect(tags).toEqual(["dunk"]);
});

it("refuses a duplicate", async () => {
  const wrapper = mount(TagInput, { props: { modelValue: ["dunk"] } });

  await wrapper.find("input").setValue("dunk");
  await wrapper.find("input").trigger("keydown.enter");

  expect(wrapper.emitted("update:modelValue")).toBeUndefined();
});

wrapper.emitted() records every event. The double array is the shape that catches people out: the outer index is which emission, the inner array is that emission's arguments.

The second assertion in the first test is the valuable one — it pins down the props-are-read-only rule from lesson 10 so a later refactor to push() fails loudly.

await every interaction. Vue updates the DOM asynchronously, so trigger and setValue return promises. Forgetting the await is the most common reason a Vue Test Utils assertion fails on a component that plainly works.

Async, and stubbing the API

import { flushPromises, mount } from "@vue/test-utils";
import { vi } from "vitest";
import * as api from "../src/api";

it("shows the empty state when the search returns nothing", async () => {
  vi.spyOn(api.api, "search").mockResolvedValue({ content: [], totalPages: 0 });

  const wrapper = mount(ExploreView, { global: { plugins: [router, createPinia()] } });
  await flushPromises();   // let the pending promises settle and the DOM update

  expect(wrapper.text()).toContain("Nothing matched");
});

flushPromises() is the async equivalent of await nextTick() — it waits for the microtask queue to drain, so both the request and the re-render it caused have finished.

Note that mocking one module is only possible because the whole application imports the API from one place (lesson 22). A component calling fetch directly is much harder to test, which is a design argument as much as a testing one.

Testing a store

Pinia stores are plain functions and need no component at all:

import { createPinia, setActivePinia } from "pinia";
import { beforeEach, expect, it } from "vitest";
import { useToastStore } from "../src/stores/toast";

beforeEach(() => {
  // A fresh Pinia per test, or state leaks between them.
  setActivePinia(createPinia());
});

it("dismisses by id and leaves the others alone", () => {
  const toast = useToastStore();

  const a = toast.success("Saved.");
  const b = toast.error("Nope.");
  toast.dismiss(a);

  expect(toast.items).toHaveLength(1);
  expect(toast.items[0].id).toBe(b);
});

The beforeEach is not optional. Stores are singletons, so without a fresh Pinia the second test inherits the first one's state and the failures are order-dependent and baffling.

End-to-end tests

These are the demo application's actual tests, and its config states the philosophy plainly:

 * These are TRUE end-to-end tests: they drive a real browser against the real Vue
 * app talking to the real Spring Boot API and a real MongoDB. Nothing is stubbed,
 * because the things most worth testing here (cursor paging, the change stream,
 * role enforcement) only exist once all three are running.
 *
 * Both servers must already be up:
 *   docker compose up -d
 *   cd reelcms-springboot-backend && ./mvnw spring-boot:run
 *   cd reelcms-vue-frontend && npm run dev

Nothing is stubbed, because the things most worth testing here — cursor paging, the change stream, role enforcement — only exist once all three tiers are running.

  test("scrolling the feed loads the next page via the cursor", async ({ page }) => {
    await page.goto("/");
    await page.locator(".reel-stage").first().waitFor();

    const before = await page.locator(".feed-slide").count();

    // Scroll to the bottom of the pager, which trips the IntersectionObserver
    // prefetch two slides from the end.
    await page.locator(".feed-scroller").evaluate((el) => el.scrollTo(0, el.scrollHeight));
    await page.waitForTimeout(1500);

    const after = await page.locator(".feed-slide").count();
    expect(after).toBeGreaterThanOrEqual(before);
  });

That test would be nearly impossible as a unit test. It needs a real scroll, a real IntersectionObserver, a real cursor query and a real database — and it is exactly the integration that breaks when someone changes the prefetch threshold.

The suite also does something worth copying: it never hard-codes a slug.

/** The first published reel, straight from the API - slugs change on every reseed. */
export async function firstPublishedReel(request) {
  const res = await request.get(`${API}/api/feed?limit=1`);
  const body = await res.json();
  return body.items[0];
}

Test data is reseeded and ids change. Asking the API what exists is the difference between a suite that survives a reseed and one that needs editing every time.

What to test, and how much

Chasing a coverage number produces tests of getters. Aim at these instead:

Branches. The fallback, the empty state, the error path. The happy path is usually covered by the app simply working.

Component contracts. Props in, events out — the surface other code depends on.

Logic you extracted. Composables and store actions are plain functions and the cheapest things in the codebase to test.

Bugs. Every fix gets a test that fails without it. This is the highest-value test you will ever write, because it is proof the bug was real and a guarantee it stays fixed.

And a few end-to-end tests over the flows that would be catastrophic to break — signing in, checking out, publishing. They are slow and they need the whole stack, so keep the list short and make each one count.

Next: Building and Deploying to Production.