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.
In depth
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 / edge cases
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).
What a senior is expected to say 6
- “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.mockties 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
withSetuphelper 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
- Component library testing patterns: Design: A Component Library
- Accessibility tooling: Accessibility — Senior Interview Prep
- Testing strategy general: Frontend Testing — Senior Interview Prep
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