Testing Vue Apps
Vitest and Vue Testing Library, with complete component test examples.
Vitest and Vue Testing Library
Vitest is the modern default test runner for Vue projects — built by (and sharing configuration with) Vite itself, so it understands .vue Single File Components and a project's existing Vite config with no extra setup layer. Vue Testing Library (part of the same Testing Library family used for React, Angular, and Svelte) provides the actual component-rendering and querying API, built around the same philosophy across every framework it supports: test a component the way a real user interacts with it — finding elements by visible text, label, or role — rather than reaching into its internal state or calling its methods directly.
npm install --save-dev vitest @testing-library/vue @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.js
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
environment: "jsdom", // simulates a browser DOM in Node
globals: true, // use describe/it/expect without importing them in every file
},
});
Querying by role or label, rather than a CSS selector or a test-only attribute, has the same practical payoff here it does in every other framework's Testing Library variant: a test written this way keeps passing through an internal refactor (switching ref for reactive, restructuring a composable) as long as what the component actually shows and does for the user stays the same.
A complete example: testing a counter component
The component under test:
<!-- Counter.vue -->
<script setup>
import { ref } from "vue";
const count = ref(0);
function increment() {
count.value++;
}
function reset() {
count.value = 0;
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="reset">Reset</button>
</div>
</template>
Its test:
// Counter.test.js
import { render, screen } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter.vue";
describe("Counter", () => {
it("starts at zero", () => {
render(Counter);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
it("increments when the Increment button is clicked", async () => {
const user = userEvent.setup();
render(Counter);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
});
it("resets back to zero", async () => {
const user = userEvent.setup();
render(Counter);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
});
render(Counter) mounts the component into a jsdom-simulated DOM, the same underlying environment React's and Svelte's Testing Library variants use. screen.getByRole("button", { name: "Increment" }) finds the button by its accessible role and visible text, exactly the way a real user or a screen reader would locate it. userEvent.setup() plus await user.click(...) fires a realistic sequence of browser events, rather than the lower-level, single-event fireEvent.click(...) — prefer userEvent for anything simulating genuine user interaction.
Testing a component with props and emitted events
<!-- LikeButton.vue -->
<script setup>
const props = defineProps({
initiallyLiked: { type: Boolean, default: false },
});
const emit = defineEmits(["liked"]);
function handleClick() {
emit("liked", { timestamp: Date.now() });
}
</script>
<template>
<button @click="handleClick">
{{ initiallyLiked ? "Liked" : "Like" }}
</button>
</template>
// LikeButton.test.js
import { render, screen, fireEvent } from "@testing-library/vue";
import LikeButton from "./LikeButton.vue";
describe("LikeButton", () => {
it("renders the initial label based on the initiallyLiked prop", () => {
render(LikeButton, { props: { initiallyLiked: true } });
expect(screen.getByRole("button", { name: "Liked" })).toBeInTheDocument();
});
it("emits a liked event with a timestamp when clicked", async () => {
const { emitted } = render(LikeButton);
await fireEvent.click(screen.getByRole("button", { name: "Like" }));
expect(emitted()).toHaveProperty("liked");
expect(emitted().liked[0][0]).toHaveProperty("timestamp");
});
});
render(LikeButton, { props: { ... } }) is how Vue Testing Library passes props into the component under test. emitted() is specific to Vue Testing Library (there's no exact equivalent in the React variant, since React doesn't have Vue's distinct emitted-event concept) — it returns every event the component emitted during the test, keyed by event name, with each call's arguments captured as an array; emitted().liked[0][0] reads the first argument of the first liked emission.
Common mistakes
- Querying with
container.querySelector(".btn-primary")instead ofgetByRole/getByLabelText— it works, but couples the test to CSS class names and internal markup structure that can change without any real change in user-facing behavior. - Forgetting
awaitbefore auserEvent/fireEventcall and before assertions that depend on Vue's reactivity flushing — Vue's DOM updates are applied asynchronously (in a microtask), so an assertion made immediately without awaiting can run before the update has actually rendered. - Testing a component's internal
refvalues or calling its internal functions directly instead of asserting on what actually renders — Vue Testing Library has no built-in API for reaching into component internals, precisely to keep tests focused on observable behavior. - Reaching for a full component test when a plain unit test of an extracted composable (a plain function, no rendering needed) would be simpler and faster for logic that doesn't actually depend on the DOM.