Files
2025-11-29 17:52:04 +08:00

6.7 KiB

Vue Testing Patterns Reference

Table of Contents

Testing Philosophy

Gold standard: Test user behavior, not implementation details.

Note: For testing composables in isolation, see @testing-composables.md which covers independent vs dependent composables, withSetup helper, and inject testing.

Primary Approach: Testing Library

// ✅ CORRECT: Testing Library - user behavior
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John Doe' }
    ])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('loads and displays users', async () => {
  render(UserList)

  // Wait for user-visible content
  expect(await screen.findByText('John Doe')).toBeInTheDocument()
})

test('submits form on button click', async () => {
  const user = userEvent.setup()
  render(UserForm)

  await user.type(screen.getByLabelText('Name'), 'Jane')
  await user.click(screen.getByRole('button', { name: 'Submit' }))

  expect(await screen.findByText('Form submitted')).toBeInTheDocument()
})

// ❌ WRONG: Testing implementation details
import { mount } from '@vue/test-utils'

test('sets isLoading to true', () => {
  const wrapper = mount(UserList)
  expect(wrapper.vm.isLoading).toBe(true) // Internal state
})

// ❌ WRONG: Arbitrary timeouts
test('loads users', async () => {
  const wrapper = mount(UserList)
  await new Promise(resolve => setTimeout(resolve, 1000)) // BAD
  expect(wrapper.text()).toContain('John')
})

Rules:

  • PRIMARY: @testing-library/vue for user-behavior tests
  • NEVER use arbitrary setTimeout() in tests
  • Use findBy* queries for async content (built-in waiting)
  • Use MSW (msw) for API mocking, not test-utils mocks
  • Query by accessibility (role, label) not test IDs
  • Fallback to @vue/test-utils ONLY for component library testing

Async Testing

// ✅ CORRECT: findBy* queries (built-in waiting)
expect(await screen.findByText('Loaded')).toBeInTheDocument()

// ✅ CORRECT: waitFor with condition
import { waitFor } from '@testing-library/vue'
await waitFor(() => {
  expect(screen.getByText('Loaded')).toBeInTheDocument()
})

// ✅ CORRECT: flushPromises (when using test-utils)
import { flushPromises } from '@vue/test-utils'
await flushPromises()

// ❌ WRONG: Arbitrary timeout
await new Promise(r => setTimeout(r, 1000))

MSW API Mocking

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

// Define handlers
const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'John' },
      { id: 2, name: 'Jane' }
    ])
  }),

  http.post('/api/users', async ({ request }) => {
    const newUser = await request.json()
    return HttpResponse.json({ id: 3, ...newUser }, { status: 201 })
  }),

  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  })
]

const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// Override handler for specific test
test('handles API error', async () => {
  server.use(
    http.get('/api/users', () => {
      return new HttpResponse(null, { status: 500 })
    })
  )

  render(UserList)
  expect(await screen.findByText('Error loading users')).toBeInTheDocument()
})

Testing Library Queries Priority

Use this priority order:

  1. Accessible queries (best)

    • getByRole('button', { name: 'Submit' })
    • getByLabelText('Email')
    • getByPlaceholderText('Enter email')
    • getByText('Welcome')
  2. Semantic queries

    • getByAltText('Profile photo')
    • getByTitle('Close')
  3. Test IDs (last resort)

    • getByTestId('submit-btn') - only when nothing else works
// ✅ BEST: Accessible queries
const submitBtn = screen.getByRole('button', { name: 'Submit' })
const emailInput = screen.getByLabelText('Email')

// ⚠️ FALLBACK: Test IDs only if accessibility not possible
const modal = screen.getByTestId('user-modal')

User Interactions

import userEvent from '@testing-library/user-event'

test('user interactions', async () => {
  const user = userEvent.setup()
  render(ContactForm)

  // Type text
  await user.type(screen.getByLabelText('Name'), 'John Doe')

  // Click
  await user.click(screen.getByRole('button', { name: 'Submit' }))

  // Select dropdown
  await user.selectOptions(screen.getByLabelText('Country'), 'USA')

  // Upload file
  const file = new File(['hello'], 'hello.png', { type: 'image/png' })
  const input = screen.getByLabelText('Upload avatar')
  await user.upload(input, file)

  // Keyboard
  await user.keyboard('{Enter}')
  await user.keyboard('{Escape}')
})

Component Library Testing (Fallback)

When testing component libraries (not applications), @vue/test-utils is acceptable:

import { mount } from '@vue/test-utils'

test('Button component emits click event', async () => {
  const wrapper = mount(Button, {
    props: { label: 'Click me' }
  })

  await wrapper.trigger('click')

  expect(wrapper.emitted('click')).toHaveLength(1)
})

Only use @vue/test-utils when:

  • Testing reusable component libraries
  • Need to test component API (props, emits, slots)
  • Not testing application behavior

Common Testing Mistakes

Mistake Fix
setTimeout(1000) Use findBy* or waitFor()
Testing wrapper.vm.isLoading Test visible UI, not internal state
getByTestId everywhere Use getByRole, getByLabelText
Mocking with jest.mock Use MSW for API mocking
wrapper.trigger('click') Use userEvent.click() for realism
Testing implementation Test user-visible behavior

Testing Checklist

  • Import from @testing-library/vue, not @vue/test-utils
  • Import userEvent from @testing-library/user-event
  • Use MSW for API mocking
  • Query by getByRole, getByLabelText, not getByTestId
  • Async content? Use findBy* queries
  • NEVER use setTimeout() for waiting
  • Test what users see, not component internals