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.
// 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:
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.
// 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.
Testing composables.
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.
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:
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):
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:
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; forgettingawaitmeans the assertion runs before the re-render.shallowMountvsmount—shallowMountstubs child components; faster but doesn’t test integration. Usemountby default.- Testing Library’s
getBy*throws if not found;queryBy*returns null;findBy*waits. Pick by intent. vi.useFakeTimers()forsetTimeout/setInterval-dependent code. Don’t forgetvi.useRealTimers()inafterEach.- Jsdom doesn’t implement everything —
IntersectionObserver,ResizeObserver,matchMedianeed stubbing. Vitest has--environment=happy-domfor a faster alternative. - MSW handler order matters — last registered wins for the same URL.
- Async composables /
onMounted— if the composable does async work inonMounted, the test mustawaitfor it (use Testing Library’sfindBy*orwaitFor).
Related
Further reading
- Vitest docs: https://vitest.dev/
- Vue Test Utils: https://test-utils.vuejs.org/
- Testing Library — Vue: https://testing-library.com/docs/vue-testing-library/intro
- MSW: https://mswjs.io/
- Testing Library — Guiding principles: https://testing-library.com/docs/guiding-principles
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
fetchpath runs.vi.mockon 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
injectneeds a smallwithSetuphelper 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.