Vue 3 Component Testing with Testing Library

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 jsdom

vitest.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 userEvent over fireEvent — 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.

Read more