Vue 3 Component Testing with Testing Library
@testing-library/vue applies Testing Library's core philosophy to Vue 3 component tests: query elements the way users find them, interact with the interface like a user would, and assert on visible outcomes rather than implementation details.
This guide shows how to write Vue 3 component tests with Testing Library — focusing on accessibility-first queries, user interactions, and async behaviors.
Why Testing Library for Vue 3 Components
The alternative to Testing Library is @vue/test-utils, which is excellent but makes it easy to write tests that know too much about internal implementation. With Testing Library:
- You query by role, label, text, and placeholder — the same signals users and screen readers use
- You trigger real DOM events, not synthetic Vue wrapper events
- Your tests break when user-visible behavior changes, not when you rename internal variables
This makes tests more resilient and more meaningful.
Installation
npm install -D @testing-library/vue @testing-library/user-event @testing-library/jest-dom vitest jsdomvitest.config.ts:
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
});tests/setup.ts:
import '@testing-library/jest-dom';Your First Component Test
<!-- components/SearchBar.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const emit = defineEmits<{
search: [query: string];
}>();
const query = ref('');
function handleSubmit() {
if (query.value.trim()) {
emit('search', query.value.trim());
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<label for="search">Search</label>
<input
id="search"
v-model="query"
type="search"
placeholder="Search products..."
/>
<button type="submit" :disabled="!query.trim()">
Search
</button>
</form>
</template>// tests/components/SearchBar.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import SearchBar from '~/components/SearchBar.vue';
describe('SearchBar', () => {
it('renders search input and button', () => {
render(SearchBar);
// Query by accessibility role and label
expect(screen.getByRole('searchbox', { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument();
});
it('submit button is disabled when input is empty', () => {
render(SearchBar);
const button = screen.getByRole('button', { name: /search/i });
expect(button).toBeDisabled();
});
it('emits search event when form is submitted', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(SearchBar, {
props: { onSearch },
});
const input = screen.getByRole('searchbox');
await user.type(input, 'vue testing');
await user.click(screen.getByRole('button', { name: /search/i }));
expect(onSearch).toHaveBeenCalledWith('vue testing');
});
it('trims whitespace from query before emitting', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(SearchBar, { props: { onSearch } });
await user.type(screen.getByRole('searchbox'), ' vue testing ');
await user.keyboard('{Enter}');
expect(onSearch).toHaveBeenCalledWith('vue testing');
});
it('does not emit for whitespace-only input', async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(SearchBar, { props: { onSearch } });
await user.type(screen.getByRole('searchbox'), ' ');
await user.keyboard('{Enter}');
expect(onSearch).not.toHaveBeenCalled();
});
});Query Priority: The Testing Library Way
Testing Library provides queries in order of preference. Use higher-priority queries when possible:
// ✅ Best: Role + accessible name (what screen readers use)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('checkbox', { name: /agree to terms/i })
// ✅ Good: Label text
screen.getByLabelText(/email address/i)
// ✅ Good: Placeholder (for inputs without visible labels)
screen.getByPlaceholderText(/enter your email/i)
// ✅ OK: Visible text
screen.getByText(/no results found/i)
// ⚠️ Fallback: Test ID (for dynamic or complex elements)
screen.getByTestId('product-price')
// ❌ Avoid: CSS selectors, class names, internal ref names
container.querySelector('.btn-primary')
wrapper.find('.product-card')Testing Async Component Behavior
Most Vue 3 components fetch data or respond to user input asynchronously. Testing Library provides findBy* queries (which wait) and waitFor for complex assertions:
<!-- components/ProductList.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const products = ref<{ id: number; name: string; price: number }[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
onMounted(async () => {
try {
const response = await fetch('/api/products');
if (!response.ok) throw new Error('Failed to load products');
products.value = await response.json();
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<div v-if="loading" role="status" aria-label="Loading products">Loading...</div>
<div v-else-if="error" role="alert">{{ error }}</div>
<ul v-else aria-label="Product list">
<li v-for="product in products" :key="product.id">
{{ product.name }} — €{{ product.price }}
</li>
</ul>
</div>
</template>// tests/components/ProductList.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/vue';
import ProductList from '~/components/ProductList.vue';
describe('ProductList', () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it('shows loading state initially', () => {
// Never resolve fetch — component stays loading
vi.mocked(fetch).mockReturnValue(new Promise(() => {}));
render(ProductList);
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
});
it('renders products after loading', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{ id: 1, name: 'Widget Pro', price: 29.99 },
{ id: 2, name: 'Gadget Max', price: 49.99 },
]),
} as Response);
render(ProductList);
// Wait for products to appear (loading disappears, products render)
const list = await screen.findByRole('list', { name: /product list/i });
const items = within(list).getAllByRole('listitem');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('Widget Pro');
expect(items[1]).toHaveTextContent('Gadget Max');
});
it('displays error message when fetch fails', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
} as Response);
render(ProductList);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent('Failed to load products');
});
it('hides loading indicator after products load', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: 1, name: 'Widget', price: 9.99 }]),
} as Response);
render(ProductList);
expect(screen.getByRole('status')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
});
});Testing Forms
Forms are among the most important components to test. Test every input type and submission path:
<!-- components/ContactForm.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue';
const form = reactive({
name: '',
email: '',
message: '',
category: 'general',
subscribe: false,
});
const submitted = ref(false);
const errors = reactive<Record<string, string>>({});
function validate() {
errors.name = form.name ? '' : 'Name is required';
errors.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) ? '' : 'Valid email required';
errors.message = form.message.length >= 10 ? '' : 'Message must be at least 10 characters';
return !Object.values(errors).some(Boolean);
}
function handleSubmit() {
if (validate()) {
submitted.value = true;
}
}
</script>
<template>
<div v-if="submitted" role="status">
Thank you! We'll be in touch.
</div>
<form v-else @submit.prevent="handleSubmit" novalidate>
<div>
<label for="name">Name</label>
<input id="name" v-model="form.name" type="text" />
<span v-if="errors.name" role="alert">{{ errors.name }}</span>
</div>
<div>
<label for="email">Email</label>
<input id="email" v-model="form.email" type="email" />
<span v-if="errors.email" role="alert">{{ errors.email }}</span>
</div>
<div>
<label for="message">Message</label>
<textarea id="message" v-model="form.message" />
<span v-if="errors.message" role="alert">{{ errors.message }}</span>
</div>
<div>
<label for="category">Category</label>
<select id="category" v-model="form.category">
<option value="general">General</option>
<option value="support">Support</option>
<option value="billing">Billing</option>
</select>
</div>
<label>
<input v-model="form.subscribe" type="checkbox" />
Subscribe to newsletter
</label>
<button type="submit">Send</button>
</form>
</template>// tests/components/ContactForm.test.ts
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import ContactForm from '~/components/ContactForm.vue';
describe('ContactForm', () => {
async function fillValidForm() {
const user = userEvent.setup();
await user.type(screen.getByLabelText(/^name/i), 'Alice Smith');
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello, I have a question about your product');
return user;
}
it('renders all form fields', () => {
render(ContactForm);
expect(screen.getByLabelText(/^name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/message/i)).toBeInTheDocument();
expect(screen.getByLabelText(/category/i)).toBeInTheDocument();
expect(screen.getByLabelText(/subscribe/i)).toBeInTheDocument();
});
it('submits form with valid data', async () => {
render(ContactForm);
const user = await fillValidForm();
await user.click(screen.getByRole('button', { name: /send/i }));
expect(await screen.findByRole('status')).toHaveTextContent("Thank you!");
});
it('shows validation errors on invalid submission', async () => {
const user = userEvent.setup();
render(ContactForm);
// Submit without filling anything
await user.click(screen.getByRole('button', { name: /send/i }));
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThan(0);
expect(alerts.some((a) => a.textContent?.includes('required'))).toBe(true);
});
it('validates email format', async () => {
const user = userEvent.setup();
render(ContactForm);
await user.type(screen.getByLabelText(/^name/i), 'Alice');
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.type(screen.getByLabelText(/message/i), 'Test message content');
await user.click(screen.getByRole('button', { name: /send/i }));
expect(screen.getByRole('alert')).toHaveTextContent('Valid email required');
});
it('changes category selection', async () => {
const user = userEvent.setup();
render(ContactForm);
const select = screen.getByLabelText(/category/i) as HTMLSelectElement;
await user.selectOptions(select, 'support');
expect(select.value).toBe('support');
});
it('toggles newsletter subscription', async () => {
const user = userEvent.setup();
render(ContactForm);
const checkbox = screen.getByLabelText(/subscribe/i) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
await user.click(checkbox);
expect(checkbox.checked).toBe(true);
});
});Testing Component Props and Slots
// components/Alert.vue
// Accepts: type ('info' | 'warning' | 'error'), message, and a default slot
// tests/components/Alert.test.ts
import { render, screen } from '@testing-library/vue';
import Alert from '~/components/Alert.vue';
describe('Alert', () => {
it('renders message prop', () => {
render(Alert, { props: { type: 'info', message: 'Operation successful' } });
expect(screen.getByRole('alert')).toHaveTextContent('Operation successful');
});
it('renders slot content', () => {
render(Alert, {
props: { type: 'warning' },
slots: {
default: '<strong>Custom <em>formatted</em> message</strong>',
},
});
expect(screen.getByRole('alert')).toContainHTML('<strong>');
});
it('applies correct class for error type', () => {
render(Alert, { props: { type: 'error', message: 'Something went wrong' } });
expect(screen.getByRole('alert')).toHaveClass('alert-error');
});
});Summary
Vue 3 component testing with Testing Library:
- Query by role first:
getByRole('button', { name: /submit/i })beats any CSS selector - Use
userEventoverfireEvent— it simulates real user interaction sequences - Use
findBy*for async content — it polls until the element appears or times out - Test form validation by simulating invalid submissions, not by calling validate functions directly
- Test visible outcomes — what appears on screen, not what's stored in internal refs
The result is a test suite that documents how your components behave from a user's perspective, making it clear what breaks when it breaks and why it matters.