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; 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
- “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: ../14_frontend_system_design/09_design_a_component_library.md
- Accessibility tooling: ../16_accessibility/
- Testing strategy general: ../10_testing/
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