6.7 KiB
6.7 KiB
Vue Testing Patterns Reference
Table of Contents
- Testing Philosophy
- Primary Approach: Testing Library
- Async Testing
- MSW API Mocking
- Testing Library Queries Priority
- User Interactions
- Component Library Testing (Fallback)
- Common Testing Mistakes
- Testing Checklist
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/vuefor 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-utilsONLY 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:
-
Accessible queries (best)
getByRole('button', { name: 'Submit' })getByLabelText('Email')getByPlaceholderText('Enter email')getByText('Welcome')
-
Semantic queries
getByAltText('Profile photo')getByTitle('Close')
-
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
userEventfrom@testing-library/user-event - Use MSW for API mocking
- Query by
getByRole,getByLabelText, notgetByTestId - Async content? Use
findBy*queries - NEVER use
setTimeout()for waiting - Test what users see, not component internals