244 lines
6.7 KiB
Markdown
244 lines
6.7 KiB
Markdown
# Vue Testing Patterns Reference
|
|
|
|
## Table of Contents
|
|
- [Testing Philosophy](#testing-philosophy)
|
|
- [Primary Approach: Testing Library](#primary-approach-testing-library)
|
|
- [Async Testing](#async-testing)
|
|
- [MSW API Mocking](#msw-api-mocking)
|
|
- [Testing Library Queries Priority](#testing-library-queries-priority)
|
|
- [User Interactions](#user-interactions)
|
|
- [Component Library Testing (Fallback)](#component-library-testing-fallback)
|
|
- [Common Testing Mistakes](#common-testing-mistakes)
|
|
- [Testing Checklist](#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
|
|
|
|
```ts
|
|
// ✅ 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
|
|
|
|
```ts
|
|
// ✅ 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
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
// ✅ 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
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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
|