Testing Vue 3 Components With Testing Library
Vue 3’s single-file components pack template, script and style into one file, and the Composition API moves most logic into setup. Both are pleasant to write and have specific consequences for testing: the component must be compiled before it can be rendered, v-model is sugar over a prop and an event that tests need to wire up by hand, provide and inject mean a component may depend on ancestors it cannot see, and async setup needs a Suspense boundary to render at all. This guide works through each with @testing-library/vue and Vitest, keeping every assertion on what a user would observe. It targets Vue 3.4 or later and sits under Vue and Svelte component testing.
Root Cause Analysis
Vue component tests go wrong in characteristic ways. The most common is asserting on the component’s internals — wrapper.vm.items.length — because that is what the lower-level test utilities make easy. Those tests pass while the template renders nothing, since they never looked at the template, and fail when a variable is renamed, since they are coupled to its name.
The second is v-model. In a real application a parent binds a value and receives updates; in a test there is no parent, so the component receives its initial value and emits updates into the void. A test that types into the input and then asserts on the input’s value may pass even when the component never emits the update — because the DOM input holds what was typed regardless of whether the component told anyone.
The third is invisible dependencies. A component that calls inject('currency') or uses useRouter() renders fine inside the application and throws in a bare test render, and the error message rarely names the missing provider. Tests end up wrapped in increasingly elaborate setups copied from each other, which is the moment to build one render helper and stop copying.
Reproducible Setup
A small address form with v-model, an injected country list, and a router link — enough surroundings to need every technique below.
<!-- src/components/AddressForm.vue -->
<script setup lang="ts">
import { inject, computed } from 'vue';
const model = defineModel<{ line1: string; postcode: string; country: string }>({ required: true });
const countries = inject<Array<{ code: string; name: string }>>('countries', []);
const postcodeValid = computed(() => model.value.country !== 'GB' || /^[A-Z]{1,2}\d/.test(model.value.postcode.toUpperCase()));
</script>
<template>
<form aria-label="Delivery address">
<label>Address line 1 <input v-model="model.line1" /></label>
<label>Postcode <input v-model="model.postcode" :aria-invalid="!postcodeValid" /></label>
<p v-if="!postcodeValid" role="alert">Enter a valid UK postcode</p>
<label>Country
<select v-model="model.country"><option v-for="c in countries" :key="c.code" :value="c.code">{{ c.name }}</option></select>
</label>
<RouterLink to="/help/addresses">Address help</RouterLink>
</form>
</template>
Implementation
Step 1 — Build one render helper that installs the usual surroundings. Router, stores and provided values go here once, instead of being copied into every test.
// test/render-vue.ts
import { render, type RenderOptions } from '@testing-library/vue';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
export function renderVue(component: unknown, options: RenderOptions<any> = {}, { provide = {} as Record<string, unknown> } = {}) {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:p(.*)*', component: { template: '<div />' } }] });
return {
router,
...render(component as never, {
...options,
global: {
plugins: [router, createTestingPinia({ stubActions: false })],
provide: { countries: [{ code: 'GB', name: 'United Kingdom' }, { code: 'FR', name: 'France' }], ...provide },
...options.global,
},
}),
};
}
Returning the router alongside Testing Library’s utilities lets tests that care about navigation inspect it without constructing their own. The catch-all route is deliberate: a component test is not the place to verify the route table, only that the component asks the router to go somewhere. Using createTestingPinia with actions left running means stores behave normally by default, and a test that wants to isolate the component from store logic can opt into stubbed actions explicitly.
Step 2 — Close the v-model loop in the test. Pass an update handler that re-renders with the new value, so the component behaves as it would under a real parent.
// src/components/AddressForm.test.ts
import { screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { ref } from 'vue';
import { renderVue } from '../../test/render-vue';
import AddressForm from './AddressForm.vue';
function renderForm(initial = { line1: '', postcode: '', country: 'GB' }) {
const model = ref(initial);
const utils = renderVue(AddressForm, {
props: {
modelValue: model.value,
'onUpdate:modelValue': (v: typeof initial) => { model.value = v; utils.rerender({ modelValue: v }); },
},
});
return { ...utils, model };
}
test('reports every edit to its parent', async () => {
const { model } = renderForm();
await userEvent.setup().type(screen.getByLabelText('Address line 1'), '10 Downing St');
expect(model.value.line1).toBe('10 Downing St');
});
Step 3 — Assert on derived state through the DOM. The postcode validation is a computed property; test it by what it renders, not by reading the computed.
test('flags an invalid UK postcode and clears it when corrected', async () => {
const user = userEvent.setup();
renderForm();
const postcode = screen.getByLabelText('Postcode');
await user.type(postcode, '123');
expect(screen.getByRole('alert')).toHaveTextContent('Enter a valid UK postcode');
expect(postcode).toHaveAttribute('aria-invalid', 'true');
await user.clear(postcode);
await user.type(postcode, 'SW1A 2AA');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
Step 4 — Override injected values per test. The helper supplies sensible defaults; a test that cares about the injected list overrides it explicitly.
test('offers only the injected countries', () => {
renderVue(AddressForm, { props: { modelValue: { line1: '', postcode: '', country: 'DE' } } },
{ provide: { countries: [{ code: 'DE', name: 'Germany' }] } });
expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['Germany']);
});
Step 5 — Test router links by navigation, not by href. Clicking the link and asserting the router’s current route proves the link is wired to the router rather than being a plain anchor.
test('the help link navigates within the app', async () => {
const { router } = renderForm();
await userEvent.setup().click(screen.getByRole('link', { name: 'Address help' }));
await router.isReady();
expect(router.currentRoute.value.fullPath).toBe('/help/addresses');
});
Step 6 — Wrap async setup in Suspense. A component with top-level await in setup renders nothing without a Suspense ancestor; a tiny wrapper supplies one.
import { defineComponent, h, Suspense } from 'vue';
const withSuspense = (inner: unknown) => defineComponent({ render: () => h(Suspense, null, { default: () => h(inner as never) }) });
test('an async component renders its loaded content', async () => {
renderVue(withSuspense(OrderHistory));
expect(await screen.findByRole('table', { name: 'Order history' })).toBeInTheDocument();
});
A general rule emerges from these steps: every dependency the component reaches for implicitly — through inject, a global plugin, or a composable that reads application state — should be visible in the test, either as a helper default or as an explicit override. When a test fails because a dependency is missing, the fix belongs in the helper, where every other test benefits, rather than in the one test that happened to notice.
Verification
Confirm the v-model test is meaningful by removing the emit from the component — replace defineModel with a local ref. The typed text still appears in the input, but the parent-reporting test must fail, proving it checks the contract rather than the DOM’s own behaviour.
npx vitest run src/components/AddressForm.test.ts --reporter=verbose
# ✓ reports every edit to its parent
# ✓ flags an invalid UK postcode and clears it when corrected
# ✓ offers only the injected countries
# ✓ the help link navigates within the app
Then confirm no test reaches into component internals. A search for .vm. across the component tests should return nothing, or a short, deliberate list with a comment explaining each.
Troubleshooting
Symptom: “injection ‘countries’ not found”. Diagnosis: the component was rendered without the provider its parent normally supplies. Fix: add the value to the helper’s defaults or pass it per test, as in Step 4.
Symptom: RouterLink renders as an unknown element. Diagnosis: the router plugin is not installed for the render. Fix: include a memory-history router in the helper’s global.plugins; stubbing RouterLink hides whether links actually work.
Symptom: the rerendered value is ignored. Diagnosis: rerender was called with the whole props object replaced, dropping the update handler. Fix: rerender with only the changed prop, as the helper in Step 2 does, so the handler stays attached.
Symptom: an async component renders nothing and no error appears. Diagnosis: it has top-level await in setup and no Suspense ancestor. Fix: wrap it as in Step 6 and use findBy queries to wait for the resolved content.
FAQ
Should I use @testing-library/vue or Vue Test Utils?
Testing Library for most component tests, because it keeps assertions on user-visible output. Vue Test Utils — which Testing Library is built on — is the right tool for the minority of cases where the contract is genuinely an internal one, a trade-off examined in comparing Vue Test Utils with Testing Library.
How do I test a component that uses a store?
Install a testing Pinia through the render helper and seed the store’s state before rendering. Whether to stub actions or run them depends on whether the test is about the component or the store, which is covered in testing Pinia stores and composables.
How do I handle transitions?
jsdom does not run CSS transitions, so <Transition> components can stay in intermediate states. Stub Transition and TransitionGroup in the helper’s global.stubs, which renders children immediately; test the transition itself visually in a browser if it matters.
Does this work with Nuxt components?
For components that do not depend on Nuxt’s runtime, yes. For those that use auto-imports and Nuxt composables, the Nuxt test utilities provide an environment that supplies them; the Testing Library queries and user-event interactions remain the same.
Related
- Back to Vue & Svelte Component Testing
- Testing Pinia stores and composables — the state these components depend on.
- Writing custom render helpers with providers — the helper pattern in React, for comparison.
- Testing accessible form error messaging — deeper coverage of validation feedback.