frontend / vue / 14_testing.md

Testing — Vue Test Utils + Vitest

6 min read source

Testing — Vue Test Utils + Vitest

TL;DR

Vitest is the test runner (Vite-native, Jest-compatible API). Vue Test Utils (VTU) is the official Vue mounting/inspection library. The senior approach: test behavior (renders, user interactions, emits), not implementation (component internals, watcher counts). Mount with the real DOM (@testing-library/vue builds on top of VTU and biases toward behavior tests). Mock the network with MSW, not vi.mock. Cover composables separately as plain functions.

Interview Q&A

Q: Vitest vs Jest — what’s the difference?

A:

Vitest Jest
Built on Vite — uses the same config, plugins, transformers own toolchain
Speed fast (Vite cold start, ESM-native) slower for ESM/TS-heavy projects
API Jest-compatible (describe/it/expect/vi.mock) the original
Watch mode fast (Vite HMR-style) slower
Browser-mode experimental jsdom only

For a Vue 3 + Vite project, Vitest is the obvious choice — zero extra config, same TS/JSX/CSS setup as the app build.

Q: Vue Test Utils — basic component test.

A:

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

describe("Counter", () => {
  it("starts at 0", () => {
    const wrapper = mount(Counter);
    expect(wrapper.text()).toContain("0");
  });

  it("increments on click", async () => {
    const wrapper = mount(Counter);
    await wrapper.find("button").trigger("click");
    expect(wrapper.text()).toContain("1");
  });

  it("emits change", async () => {
    const wrapper = mount(Counter);
    await wrapper.find("button").trigger("click");
    expect(wrapper.emitted()).toHaveProperty("change");
    expect(wrapper.emitted("change")?.[0]).toEqual([1]);
  });
});

Key methods: mount (full render), shallowMount (stub child components), find/findAll, trigger, setValue, emitted(). Most assertions use wrapper.text(), wrapper.html(), wrapper.attributes().

Q: @testing-library/vue vs raw VTU?

A: Testing Library wraps VTU with role-based queries that mirror how users (and screen readers) find elements:

import { render, screen } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";

it("submits the form", async () => {
  const user = userEvent.setup();
  render(LoginForm);
  await user.type(screen.getByLabelText(/email/i), "ada@example.com");
  await user.type(screen.getByLabelText(/password/i), "secret");
  await user.click(screen.getByRole("button", { name: /sign in/i }));
  expect(screen.getByText(/welcome/i)).toBeInTheDocument();
});

getByRole, getByLabelText, findByText (async), queryByText (no-throw). The query priority — Role > Label > Placeholder > Text > Test ID — mirrors accessibility-first thinking. Bias toward Testing Library unless you need VTU’s component-introspection features.

Q: How do you mock network requests?

A: MSW (Mock Service Worker) — intercepts at the network layer, so your code runs its real fetch logic against handlers you define.

// test/setup.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

export const server = setupServer(
  http.get("/api/users", () => HttpResponse.json([{ id: 1, name: "Ada" }])),
  http.post("/api/orders", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: "ord_1", ...body }, { status: 201 });
  }),
);

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Per-test override:

it("handles server error", async () => {
  server.use(http.get("/api/users", () => HttpResponse.error()));
  render(UserList);
  expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
});

Why MSW over vi.mock: MSW tests the actual fetch path, including error handling and retry logic. vi.mock stubs functions, which is brittle (refactor breaks tests) and skips the real network code.

Q: Testing composables.

A: Composables are plain functions returning refs/methods — test directly with a small wrapper:

import { withSetup } from "./testUtils";
import { useCounter } from "./useCounter";

it("counter increments", () => {
  const { result } = withSetup(() => useCounter(0));
  expect(result.count.value).toBe(0);
  result.increment();
  expect(result.count.value).toBe(1);
});

Where withSetup mounts an empty component to provide a reactive context:

// testUtils.ts
import { createApp, h, type App } from "vue";

export function withSetup<T>(composable: () => T) {
  let result!: T;
  const app = createApp({ setup() { result = composable(); return () => h("div"); } });
  app.mount(document.createElement("div"));
  return { result, app };
}

If the composable uses lifecycle hooks (onMounted), the empty mount runs them. For composables that need provided values, mount with a parent that provides.

Q: Snapshot testing — when?

A: Sparingly. Snapshots calcify the exact output and break on any cosmetic change, encouraging “just update the snapshot” without thought. Use them only:

  • For pure data transformations (the input/output of a utility function).
  • For tiny presentational components where every change should be reviewed.

Avoid for whole-component HTML — behavior tests are more durable.

Q: How do you test routing?

A: Mount with a real vue-router instance:

import { createRouter, createMemoryHistory } from "vue-router";
import { mount } from "@vue/test-utils";

const router = createRouter({
  history: createMemoryHistory(),
  routes: [{ path: "/", component: Home }, { path: "/users", component: UserList }],
});

it("navigates to users on click", async () => {
  router.push("/");
  await router.isReady();
  const wrapper = mount(App, { global: { plugins: [router] } });
  await wrapper.find('a[href="/users"]').trigger("click");
  expect(router.currentRoute.value.path).toBe("/users");
});

createMemoryHistory avoids touching the URL bar in tests.

Q: Testing Pinia stores.

A: Per 10_pinia.md:

import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, it, expect } from "vitest";
import { useCounter } from "@/stores/counter";

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

  it("increments", () => {
    const store = useCounter();
    store.increment();
    expect(store.count).toBe(1);
  });
});

For component tests using stores, mount with global.plugins: [createPinia()].

Q: Testing pyramid for a Vue app.

A:

Tier What Tool
Unit composables, utils, pure logic Vitest
Component individual component behavior, props/emits Testing Library + Vitest
Integration several components together with MSW mocks Testing Library + MSW
E2E full user journeys, real backend or staging Playwright (or Cypress)
Visual regression screenshot diffs, prevent UI regressions Chromatic, Percy, Playwright snapshots

Bias toward integration (mid-level) — they exercise enough surface to catch real bugs without the brittleness of full E2E.

Q: a11y testing.

A: axe-core via vitest-axe or @axe-core/vue:

import { axe } from "vitest-axe";

it("login form is accessible", async () => {
  const { container } = render(LoginForm);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Catches color contrast, missing labels, invalid ARIA. Doesn’t catch screen-reader-flow bugs (those need manual testing) but catches the static-analysis low-hanging fruit.

Gotchas / edge cases

  • await wrapper.trigger("click") — VTU’s events are async; forgetting await means the assertion runs before the re-render.
  • shallowMount vs mountshallowMount stubs child components; faster but doesn’t test integration. Use mount by default.
  • Testing Library’s getBy* throws if not found; queryBy* returns null; findBy* waits. Pick by intent.
  • vi.useFakeTimers() for setTimeout/setInterval-dependent code. Don’t forget vi.useRealTimers() in afterEach.
  • Jsdom doesn’t implement everythingIntersectionObserver, ResizeObserver, matchMedia need stubbing. Vitest has --environment=happy-dom for a faster alternative.
  • MSW handler order matters — last registered wins for the same URL.
  • Async composables / onMounted — if the composable does async work in onMounted, the test must await for it (use Testing Library’s findBy* or waitFor).

What a senior is expected to say

  • “Vitest as the runner — Vite-native, Jest-compatible API. Testing Library on top of VTU for behavior-first testing with role-based queries.”
  • “MSW for network mocking. vi.mock ties tests to implementation and breaks on refactor; MSW intercepts at the network so the real fetch path runs.”
  • “Composables are plain functions — test them directly with a small withSetup helper if they use lifecycle/reactivity. Pure logic stays unit-testable.”
  • “Bias toward integration tests; reserve full E2E for the critical user journeys. Snapshots only for tiny presentational components or data transforms.”
  • “axe-core in CI catches the static-analysis a11y issues. Visual regression (Chromatic / Percy) for design system changes.”
  • “Per-tier ownership: composables = unit, components = Testing Library, journeys = Playwright.”

Cross-references

Further reading