Frontend / Vue / 14_testing.md

Testing — Vue Test Utils + Vitest

Updated 5 interview angles 6 min read source
On this page14
  1. Vitest vs Jest — what’s the difference?
  2. Vue Test Utils — basic component test.
  3. @testing-library/vue vs raw VTU?
  4. How do you mock network requests?
  5. Testing composables.
  6. Snapshot testing — when?
  7. How do you test routing?
  8. Testing Pinia stores.
  9. Testing pyramid for a Vue app.
  10. a11y testing.
  11. Gotchas
  12. Related
  13. Further reading
  14. Interview angle

Testing — Vue Test Utils + Vitest

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.

Vitest vs Jest — what’s the difference?

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.

Vue Test Utils — basic component test.

ts
// 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().

@testing-library/vue vs raw VTU?

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

ts
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.

How do you mock network requests?

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

ts
// 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:

ts
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.

Testing composables.

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

ts
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:

ts
// 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.

Snapshot testing — when?

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.

How do you test routing?

Mount with a real vue-router instance:

ts
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.

Testing Pinia stores.

Per Pinia (and Where Vuex Still Appears):

ts
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()].

Testing pyramid for a Vue app.

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.

a11y testing.

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

ts
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

  • await wrapper.trigger("click") — VTU’s events are async; forgetting await means the assertion runs before the re-render.
  • shallowMount vs mount — shallowMount 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 everything — IntersectionObserver, 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).

Further reading

Interview angle 5

  • “What is the Vue testing stack?” - Vitest as the runner, since it is Vite-native and shares the app’s transform pipeline, with Testing Library on top of Vue Test Utils so tests query by role and text rather than by internal component structure.
  • “How do you handle network calls in tests?” - MSW, intercepting at the network layer so the real fetch path runs. vi.mock on the API module ties the test to the implementation, so a refactor that changes nothing observable still breaks the suite.
  • “How do you test a composable?” - directly, because it is a plain function. Pure logic needs no harness at all; one that uses lifecycle hooks or inject needs a small withSetup helper to give it a component instance.
  • “What is the right test mix?” - weighted toward integration tests that render a component with its real children, with Playwright reserved for the handful of critical journeys. Snapshots only for tiny presentational output or pure data transforms, where a diff is actually readable.
  • “What do you automate for accessibility?” - axe-core in CI, which catches the statically detectable issues - missing labels, contrast, bad ARIA - and visual regression tooling for design-system changes. Neither replaces keyboard testing, and it is worth saying so.