1013 lines
25 KiB
Markdown
1013 lines
25 KiB
Markdown
# Testing Reference (Jest + React Testing Library)
|
|
|
|
## Core Philosophy
|
|
|
|
**"The more your tests resemble the way your software is used, the more confidence they can give you."** - Kent C. Dodds
|
|
|
|
### Test user behavior, not implementation details
|
|
|
|
**Implementation detail**: Internal state, methods, component lifecycle
|
|
**User behavior**: What users see, click, type, and read
|
|
|
|
## Query Priority
|
|
|
|
Always use queries in this priority order:
|
|
|
|
### 1. Accessible queries (Preferred)
|
|
|
|
**getByRole** - Most preferred, tests accessibility
|
|
```typescript
|
|
// Buttons
|
|
screen.getByRole('button', { name: /submit/i })
|
|
screen.getByRole('button', { name: /cancel/i })
|
|
|
|
// Links
|
|
screen.getByRole('link', { name: /home/i })
|
|
|
|
// Inputs
|
|
screen.getByRole('textbox', { name: /email/i })
|
|
screen.getByRole('textbox', { name: /password/i })
|
|
|
|
// Checkboxes
|
|
screen.getByRole('checkbox', { name: /remember me/i })
|
|
|
|
// Headings
|
|
screen.getByRole('heading', { name: /welcome/i, level: 1 })
|
|
|
|
// Lists
|
|
screen.getByRole('list')
|
|
screen.getAllByRole('listitem')
|
|
|
|
// Dialogs/Modals
|
|
screen.getByRole('dialog')
|
|
screen.getByRole('alertdialog')
|
|
```
|
|
|
|
**getByLabelText** - Great for forms
|
|
```typescript
|
|
screen.getByLabelText(/email address/i)
|
|
screen.getByLabelText(/password/i)
|
|
|
|
// With exact match
|
|
screen.getByLabelText('Email Address', { exact: false })
|
|
```
|
|
|
|
### 2. Semantic queries
|
|
|
|
**getByPlaceholderText**
|
|
```typescript
|
|
screen.getByPlaceholderText(/enter your email/i)
|
|
```
|
|
|
|
**getByText**
|
|
```typescript
|
|
screen.getByText(/welcome back/i)
|
|
screen.getByText(/loading/i)
|
|
|
|
// Partial match
|
|
screen.getByText(/welcome/i) // Matches "Welcome back!"
|
|
```
|
|
|
|
**getByDisplayValue** - For form fields with values
|
|
```typescript
|
|
screen.getByDisplayValue(/john@example.com/i)
|
|
```
|
|
|
|
### 3. Test IDs (Last resort)
|
|
|
|
Only use when accessibility queries don't work:
|
|
```typescript
|
|
screen.getByTestId('custom-complex-component')
|
|
```
|
|
|
|
## Query Variants
|
|
|
|
### get* - Element must exist
|
|
```typescript
|
|
// Throws if not found
|
|
const button = screen.getByRole('button')
|
|
```
|
|
|
|
### query* - Element may not exist
|
|
```typescript
|
|
// Returns null if not found
|
|
const button = screen.queryByRole('button')
|
|
expect(button).not.toBeInTheDocument()
|
|
```
|
|
|
|
### find* - Element appears asynchronously
|
|
```typescript
|
|
// Returns promise, waits up to 1000ms by default
|
|
const button = await screen.findByRole('button')
|
|
```
|
|
|
|
### getAll*, queryAll*, findAll* - Multiple elements
|
|
```typescript
|
|
const items = screen.getAllByRole('listitem')
|
|
expect(items).toHaveLength(5)
|
|
```
|
|
|
|
## User Interactions (user-event)
|
|
|
|
Always use `@testing-library/user-event` over fireEvent:
|
|
|
|
```typescript
|
|
import userEvent from '@testing-library/user-event'
|
|
|
|
// Setup user (required in v14+)
|
|
const user = userEvent.setup()
|
|
|
|
// Click
|
|
await user.click(element)
|
|
await user.dblClick(element)
|
|
await user.tripleClick(element)
|
|
|
|
// Type
|
|
await user.type(input, 'Hello World')
|
|
await user.clear(input)
|
|
|
|
// Keyboard
|
|
await user.keyboard('{Enter}')
|
|
await user.keyboard('{Escape}')
|
|
await user.tab()
|
|
|
|
// Hover
|
|
await user.hover(element)
|
|
await user.unhover(element)
|
|
|
|
// Select
|
|
await user.selectOptions(select, 'option1')
|
|
|
|
// Upload file
|
|
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
|
|
await user.upload(input, file)
|
|
|
|
// Copy/paste
|
|
await user.copy()
|
|
await user.paste('clipboard content')
|
|
```
|
|
|
|
## Async Testing
|
|
|
|
### waitFor - Wait for condition
|
|
|
|
```typescript
|
|
import { waitFor } from '@testing-library/react'
|
|
|
|
// Wait for element to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/success/i)).toBeInTheDocument()
|
|
})
|
|
|
|
// Wait for element to disappear
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
|
|
})
|
|
|
|
// Custom timeout (default: 1000ms)
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
|
|
},
|
|
{ timeout: 3000 }
|
|
)
|
|
|
|
// Custom interval (default: 50ms)
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
|
|
},
|
|
{ interval: 100 }
|
|
)
|
|
```
|
|
|
|
### findBy - Shorthand for waitFor + getBy
|
|
|
|
```typescript
|
|
// Instead of:
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
|
|
})
|
|
|
|
// Use:
|
|
await screen.findByText(/loaded/i)
|
|
|
|
// They're equivalent, but findBy is more concise
|
|
```
|
|
|
|
### ❌ DON'T use arbitrary timeouts
|
|
|
|
```typescript
|
|
// ❌ Bad: Arbitrary wait
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
|
|
// ✅ Good: Wait for specific condition
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
## MSW (Mock Service Worker)
|
|
|
|
### Setup
|
|
|
|
```typescript
|
|
// src/test/mocks/handlers.ts
|
|
import { rest } from 'msw'
|
|
|
|
export const handlers = [
|
|
rest.get('/api/users', (req, res, ctx) => {
|
|
return res(
|
|
ctx.status(200),
|
|
ctx.json([
|
|
{ id: '1', name: 'Alice', email: 'alice@example.com' },
|
|
{ id: '2', name: 'Bob', email: 'bob@example.com' }
|
|
])
|
|
)
|
|
}),
|
|
|
|
rest.post('/api/users', (req, res, ctx) => {
|
|
const newUser = req.body
|
|
return res(
|
|
ctx.status(201),
|
|
ctx.json({ id: '3', ...newUser })
|
|
)
|
|
}),
|
|
|
|
rest.get('/api/users/:id', (req, res, ctx) => {
|
|
const { id } = req.params
|
|
return res(
|
|
ctx.status(200),
|
|
ctx.json({ id, name: 'Alice', email: 'alice@example.com' })
|
|
)
|
|
})
|
|
]
|
|
|
|
// src/test/mocks/server.ts
|
|
import { setupServer } from 'msw/node'
|
|
import { handlers } from './handlers'
|
|
|
|
export const server = setupServer(...handlers)
|
|
|
|
// src/test/setup.ts (imported in Jest config)
|
|
import '@testing-library/jest-dom'
|
|
import { server } from './mocks/server'
|
|
|
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
|
afterEach(() => server.resetHandlers())
|
|
afterAll(() => server.close())
|
|
```
|
|
|
|
### Override handlers in tests
|
|
|
|
```typescript
|
|
import { rest } from 'msw'
|
|
import { server } from '@/test/mocks/server'
|
|
|
|
test('handles server error', async () => {
|
|
// Override for this test only
|
|
server.use(
|
|
rest.get('/api/users', (req, res, ctx) => {
|
|
return res(
|
|
ctx.status(500),
|
|
ctx.json({ message: 'Internal server error' })
|
|
)
|
|
})
|
|
)
|
|
|
|
render(<UserList />)
|
|
|
|
expect(await screen.findByText(/error loading users/i)).toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
## Testing Patterns
|
|
|
|
### Pattern: Form Submission
|
|
|
|
```typescript
|
|
test('submits form with valid data', async () => {
|
|
const user = userEvent.setup()
|
|
const onSubmit = jest.fn()
|
|
|
|
render(<ContactForm onSubmit={onSubmit} />)
|
|
|
|
// Fill form
|
|
await user.type(screen.getByLabelText(/name/i), 'John Doe')
|
|
await user.type(screen.getByLabelText(/email/i), 'john@example.com')
|
|
await user.type(screen.getByLabelText(/message/i), 'Hello world')
|
|
|
|
// Submit
|
|
await user.click(screen.getByRole('button', { name: /submit/i }))
|
|
|
|
// Assert
|
|
expect(onSubmit).toHaveBeenCalledWith({
|
|
name: 'John Doe',
|
|
email: 'john@example.com',
|
|
message: 'Hello world'
|
|
})
|
|
})
|
|
|
|
test('shows validation errors for invalid email', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
render(<ContactForm onSubmit={jest.fn()} />)
|
|
|
|
await user.type(screen.getByLabelText(/email/i), 'invalid-email')
|
|
await user.click(screen.getByRole('button', { name: /submit/i }))
|
|
|
|
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
### Pattern: Data Fetching
|
|
|
|
```typescript
|
|
test('loads and displays user data', async () => {
|
|
render(<UserProfile userId='123' />)
|
|
|
|
// Loading state
|
|
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
|
|
|
// Wait for data to load
|
|
expect(await screen.findByText(/alice/i)).toBeInTheDocument()
|
|
|
|
// Loading indicator gone
|
|
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('handles loading error', async () => {
|
|
server.use(
|
|
rest.get('/api/users/:id', (req, res, ctx) => {
|
|
return res(ctx.status(404), ctx.json({ message: 'User not found' }))
|
|
})
|
|
)
|
|
|
|
render(<UserProfile userId='999' />)
|
|
|
|
expect(await screen.findByText(/user not found/i)).toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
### Pattern: Component with Context
|
|
|
|
```typescript
|
|
// Test helper
|
|
function renderWithProviders(
|
|
ui: React.ReactElement,
|
|
{
|
|
initialAuth = null,
|
|
theme = 'light'
|
|
} = {}
|
|
) {
|
|
return render(
|
|
<AuthProvider initialUser={initialAuth}>
|
|
<ThemeProvider initialTheme={theme}>
|
|
{ui}
|
|
</ThemeProvider>
|
|
</AuthProvider>
|
|
)
|
|
}
|
|
|
|
test('shows user menu when authenticated', () => {
|
|
const user = { id: '1', name: 'Alice', email: 'alice@example.com' }
|
|
|
|
renderWithProviders(<Navigation />, { initialAuth: user })
|
|
|
|
expect(screen.getByText(/alice/i)).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument()
|
|
})
|
|
|
|
test('shows login button when not authenticated', () => {
|
|
renderWithProviders(<Navigation />)
|
|
|
|
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
### Pattern: Custom Hooks
|
|
|
|
```typescript
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
|
|
test('useDebounce delays value update', async () => {
|
|
jest.useFakeTimers()
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ value, delay }) => useDebounce(value, delay),
|
|
{ initialProps: { value: 'initial', delay: 500 } }
|
|
)
|
|
|
|
expect(result.current).toBe('initial')
|
|
|
|
// Update value
|
|
rerender({ value: 'updated', delay: 500 })
|
|
|
|
// Still old value
|
|
expect(result.current).toBe('initial')
|
|
|
|
// Fast-forward time
|
|
jest.advanceTimersByTime(500)
|
|
|
|
// Now updated
|
|
await waitFor(() => {
|
|
expect(result.current).toBe('updated')
|
|
})
|
|
|
|
jest.useRealTimers()
|
|
})
|
|
```
|
|
|
|
### Pattern: Testing Hooks with Dependencies
|
|
|
|
```typescript
|
|
function renderHookWithProviders<T>(
|
|
hook: () => T,
|
|
{
|
|
initialAuth = null
|
|
} = {}
|
|
) {
|
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
<AuthProvider initialUser={initialAuth}>
|
|
{children}
|
|
</AuthProvider>
|
|
)
|
|
|
|
return renderHook(hook, { wrapper })
|
|
}
|
|
|
|
test('useAuth returns current user', () => {
|
|
const user = { id: '1', name: 'Alice' }
|
|
|
|
const { result } = renderHookWithProviders(
|
|
() => useAuth(),
|
|
{ initialAuth: user }
|
|
)
|
|
|
|
expect(result.current.user).toEqual(user)
|
|
expect(result.current.isAuthenticated).toBe(true)
|
|
})
|
|
```
|
|
|
|
### Pattern: Modal/Dialog
|
|
|
|
```typescript
|
|
test('opens and closes modal', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
render(<ModalExample />)
|
|
|
|
// Modal not visible initially
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
|
|
// Open modal
|
|
await user.click(screen.getByRole('button', { name: /open modal/i }))
|
|
|
|
// Modal visible
|
|
const modal = screen.getByRole('dialog')
|
|
expect(modal).toBeInTheDocument()
|
|
expect(within(modal).getByText(/modal content/i)).toBeInTheDocument()
|
|
|
|
// Close modal (via close button)
|
|
await user.click(within(modal).getByRole('button', { name: /close/i }))
|
|
|
|
// Modal hidden
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('closes modal on escape key', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
render(<ModalExample />)
|
|
|
|
await user.click(screen.getByRole('button', { name: /open modal/i }))
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
|
|
// Press escape
|
|
await user.keyboard('{Escape}')
|
|
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
### Pattern: Lists and Filtering
|
|
|
|
```typescript
|
|
test('filters users by search term', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
render(<UserList />)
|
|
|
|
// Wait for users to load
|
|
await waitFor(() => {
|
|
expect(screen.getAllByRole('listitem')).toHaveLength(10)
|
|
})
|
|
|
|
// Type in search
|
|
await user.type(screen.getByRole('textbox', { name: /search/i }), 'alice')
|
|
|
|
// Wait for filtered results
|
|
await waitFor(() => {
|
|
const items = screen.getAllByRole('listitem')
|
|
expect(items).toHaveLength(1)
|
|
expect(within(items[0]).getByText(/alice/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
### Pattern: Navigation (React Router)
|
|
|
|
```typescript
|
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
|
|
|
function renderWithRouter(
|
|
ui: React.ReactElement,
|
|
{ initialEntries = ['/'] } = {}
|
|
) {
|
|
return render(
|
|
<MemoryRouter initialEntries={initialEntries}>
|
|
<Routes>
|
|
<Route path='/' element={<HomePage />} />
|
|
<Route path='/users/:id' element={ui} />
|
|
<Route path='/login' element={<LoginPage />} />
|
|
</Routes>
|
|
</MemoryRouter>
|
|
)
|
|
}
|
|
|
|
test('navigates to user profile on click', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
renderWithRouter(<UserList />)
|
|
|
|
// Click user link
|
|
await user.click(screen.getByRole('link', { name: /alice/i }))
|
|
|
|
// Assert navigation occurred (check URL-dependent content)
|
|
expect(await screen.findByRole('heading', { name: /alice's profile/i })).toBeInTheDocument()
|
|
})
|
|
```
|
|
|
|
### Pattern: Error Boundaries
|
|
|
|
```typescript
|
|
test('error boundary catches component errors', () => {
|
|
// Suppress console.error for this test
|
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
const ThrowError = () => {
|
|
throw new Error('Test error')
|
|
}
|
|
|
|
render(
|
|
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
<ThrowError />
|
|
</ErrorBoundary>
|
|
)
|
|
|
|
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
|
|
|
|
consoleSpy.mockRestore()
|
|
})
|
|
```
|
|
|
|
## Coverage Targets
|
|
|
|
The architecture principle: **Push logic into leaf types for maximum testability**. Strive to have most of your business logic in small, focused, dependency-free leaf components/hooks/functions.
|
|
|
|
### Leaf Components/Hooks/Functions
|
|
|
|
**Definition**: Pure, self-contained units with **no external dependencies** (no API calls, database access, file system). They contain core business logic and can freely compose other leaf types.
|
|
|
|
**Key Characteristics**:
|
|
- ✅ **Can depend on other leaf types** - Domain types composing domain types (e.g., `Order` uses `Money`, `ShippingAddress`)
|
|
- ❌ **Cannot depend on external systems** - No API calls, database access, file system operations
|
|
- ✅ **Deterministic** - Same input always gives same output
|
|
- ✅ **No side effects** - Pure functions or immutable objects
|
|
- ✅ **Testable without mocks** - Just instantiate and test
|
|
|
|
**Examples**:
|
|
- **Branded types**: `Email`, `UserId`, `Price` with validation
|
|
- **Domain models**: `Order`, `Money`, `ShippingAddress` (can use other leaf types)
|
|
- **Validation functions**: `validateEmail()`, `parseDate()`
|
|
- **Custom hooks (pure)**: `useValidation()`, `useDebounce()`, `useLocalStorage()`
|
|
- **Utility functions**: `formatCurrency()`, `parseAddress()`
|
|
- **Presentational components**: `Button`, `Card`, `Input` (UI-only, no business logic)
|
|
|
|
**Coverage Target**: **100% unit test coverage**
|
|
|
|
**Why**:
|
|
- Leaf types contain core business logic and must be bulletproof
|
|
- No external dependencies means easy to test in isolation (no mocks needed)
|
|
- High confidence in these building blocks enables safe composition
|
|
- Most bugs happen in complex logic - leaf types isolate that complexity
|
|
|
|
**How to test**:
|
|
- Test only the public API (exports)
|
|
- Use real implementations, not mocks
|
|
- Cover happy path, edge cases, and error cases
|
|
- For hooks: Test with `@testing-library/react-hooks` or render in test component
|
|
- For components: Use React Testing Library with user-centric tests
|
|
|
|
**Example - Leaf Hook**:
|
|
```typescript
|
|
// useDebounce.ts - Pure hook with no dependencies
|
|
export function useDebounce<T>(value: T, delay: number): T {
|
|
const [debouncedValue, setDebouncedValue] = useState(value)
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
|
return () => clearTimeout(timer)
|
|
}, [value, delay])
|
|
|
|
return debouncedValue
|
|
}
|
|
|
|
// useDebounce.test.ts - 100% coverage
|
|
describe('useDebounce', () => {
|
|
it('returns initial value immediately', () => {
|
|
const { result } = renderHook(() => useDebounce('initial', 500))
|
|
expect(result.current).toBe('initial')
|
|
})
|
|
|
|
it('updates value after delay', async () => {
|
|
const { result, rerender } = renderHook(
|
|
({ value, delay }) => useDebounce(value, delay),
|
|
{ initialProps: { value: 'initial', delay: 500 } }
|
|
)
|
|
|
|
rerender({ value: 'updated', delay: 500 })
|
|
expect(result.current).toBe('initial') // Still old value
|
|
|
|
await waitFor(() => {
|
|
expect(result.current).toBe('updated') // Now updated
|
|
}, { timeout: 600 })
|
|
})
|
|
|
|
it('cancels pending update on unmount', () => {
|
|
const { result, unmount } = renderHook(() => useDebounce('value', 500))
|
|
unmount()
|
|
// No error should be thrown
|
|
})
|
|
})
|
|
```
|
|
|
|
**Example - Leaf Types Composing Other Leaf Types**:
|
|
```typescript
|
|
// All leaf types - can freely compose each other
|
|
class Money {
|
|
constructor(private readonly cents: number) {}
|
|
|
|
add(other: Money): Money {
|
|
return new Money(this.cents + other.cents)
|
|
}
|
|
|
|
multiply(factor: number): Money {
|
|
return new Money(Math.round(this.cents * factor))
|
|
}
|
|
}
|
|
|
|
class ShippingAddress {
|
|
constructor(
|
|
readonly street: string,
|
|
readonly city: string
|
|
) {}
|
|
|
|
isRemoteLocation(): boolean {
|
|
return this.city === 'Remote'
|
|
}
|
|
}
|
|
|
|
class Order {
|
|
constructor(
|
|
private items: CartItem[],
|
|
private shipping: ShippingAddress // ✅ Leaf using another leaf
|
|
) {}
|
|
|
|
calculateTotal(): Money { // ✅ Returns a leaf type
|
|
const subtotal = this.items.reduce(
|
|
(sum, item) => sum.add(Money.dollars(item.price)),
|
|
Money.dollars(0)
|
|
)
|
|
|
|
const shippingCost = this.shipping.isRemoteLocation()
|
|
? Money.dollars(15)
|
|
: Money.dollars(5)
|
|
|
|
return subtotal.add(shippingCost)
|
|
}
|
|
}
|
|
|
|
// Testing is trivial - no mocks needed!
|
|
describe('Order', () => {
|
|
it('calculates total with shipping', () => {
|
|
const order = new Order(
|
|
[{ price: 100 }],
|
|
new ShippingAddress('123 Main', 'Springfield')
|
|
)
|
|
expect(order.calculateTotal()).toEqual(Money.dollars(105))
|
|
})
|
|
|
|
it('adds remote shipping for remote locations', () => {
|
|
const order = new Order(
|
|
[{ price: 100 }],
|
|
new ShippingAddress('456 Main', 'Remote')
|
|
)
|
|
expect(order.calculateTotal()).toEqual(Money.dollars(115))
|
|
})
|
|
})
|
|
```
|
|
|
|
**Example - NOT Leaf Types (External Dependencies)**:
|
|
```typescript
|
|
// NOT a leaf - depends on external API
|
|
class OrderService {
|
|
constructor(private api: ApiClient) {} // ❌ External dependency
|
|
|
|
async fetchOrder(id: string): Promise<Order> {
|
|
return this.api.get(`/orders/${id}`) // ❌ API call
|
|
}
|
|
|
|
async saveOrder(order: Order): Promise<void> {
|
|
await this.api.post('/orders', order) // ❌ API call
|
|
}
|
|
}
|
|
|
|
// NOT a leaf - depends on database
|
|
class OrderRepository {
|
|
constructor(private db: Database) {} // ❌ External dependency
|
|
|
|
async findById(id: string): Promise<Order> {
|
|
return this.db.query('SELECT * FROM orders WHERE id = ?', [id]) // ❌ DB access
|
|
}
|
|
}
|
|
|
|
// NOT a leaf - custom hook that fetches data
|
|
function useOrder(orderId: string) {
|
|
const [order, setOrder] = useState<Order | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/orders/${orderId}`) // ❌ API call
|
|
.then(res => res.json())
|
|
.then(setOrder)
|
|
}, [orderId])
|
|
|
|
return order
|
|
}
|
|
|
|
// Testing these requires mocks or test servers (integration tests)
|
|
```
|
|
|
|
**Key Insight**: Complex domain models like `Order`, `Money`, `ShippingAddress` are ALL leaf types because they're pure logic. They can compose each other freely. The boundary is **external systems**, not other application types.
|
|
|
|
### Orchestrating Components/Functions
|
|
|
|
**Definition**: Coordinate multiple leaf types, hooks, or services. They compose smaller pieces but contain minimal logic themselves.
|
|
|
|
**Examples**:
|
|
- **Feature components**: `UserProfile`, `LoginForm`, `CheckoutFlow`
|
|
- **Page components**: `HomePage`, `DashboardPage`
|
|
- **Context providers**: `AuthProvider`, `ThemeProvider`
|
|
- **Container components**: Connect data (hooks/APIs) to presentation
|
|
- **Coordinator functions**: `processCheckout()` that calls multiple services
|
|
|
|
**Coverage Target**: **Integration test coverage**
|
|
|
|
**Why**:
|
|
- Test seams and interactions between leaf types
|
|
- Verify correct composition and data flow
|
|
- Can have some overlap with leaf type coverage
|
|
- Focus on behavior from user perspective
|
|
|
|
**How to test**:
|
|
- Test entire feature flows
|
|
- Use MSW (Mock Service Worker) for API calls
|
|
- Test user interactions with `userEvent`
|
|
- Verify multiple components working together
|
|
- Test loading/error/success states
|
|
|
|
**Example - Orchestrating Component**:
|
|
```typescript
|
|
// LoginForm.tsx - Orchestrates multiple pieces
|
|
export function LoginForm() {
|
|
const { login } = useAuth() // Leaf hook
|
|
const { validateEmail } = useValidation() // Leaf hook
|
|
const navigate = useNavigate()
|
|
|
|
const handleSubmit = async (email: string, password: string) => {
|
|
if (!validateEmail(email)) return
|
|
await login(email, password)
|
|
navigate('/dashboard')
|
|
}
|
|
|
|
return <LoginFormView onSubmit={handleSubmit} />
|
|
}
|
|
|
|
// LoginForm.test.tsx - Integration test (not 100% coverage required)
|
|
test('successful login navigates to dashboard', async () => {
|
|
const user = userEvent.setup()
|
|
|
|
// Mock API
|
|
server.use(
|
|
http.post('/api/login', () => {
|
|
return HttpResponse.json({ token: 'abc123' })
|
|
})
|
|
)
|
|
|
|
render(<LoginForm />)
|
|
|
|
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
|
|
await user.type(screen.getByLabelText(/password/i), 'password123')
|
|
await user.click(screen.getByRole('button', { name: /sign in/i }))
|
|
|
|
// Verify navigation happened
|
|
await waitFor(() => {
|
|
expect(window.location.pathname).toBe('/dashboard')
|
|
})
|
|
})
|
|
```
|
|
|
|
### Architectural Benefits
|
|
|
|
When you follow this pattern:
|
|
1. **Easier testing**: Leaf types are trivial to test (100% coverage achievable)
|
|
2. **Better composition**: Small, focused pieces are easier to combine
|
|
3. **Easier refactoring**: Changes isolated to leaf types
|
|
4. **Lower cognitive load**: Each piece has single responsibility
|
|
5. **Reusability**: Leaf types naturally reusable across features
|
|
|
|
**Anti-pattern**: Putting complex business logic directly in orchestrating components makes testing hard and coupling high. Always extract to leaf types.
|
|
|
|
### Coverage Strategy Summary
|
|
|
|
| Type | Coverage Target | Test Approach | Example |
|
|
|------|----------------|---------------|---------|
|
|
| Leaf | 100% unit tests | Isolated, no mocks | `useDebounce()`, `Email` type, `Button` |
|
|
| Orchestrating | Integration tests | User flows, MSW | `LoginForm`, `UserProfile`, `AuthProvider` |
|
|
|
|
## Jest Matchers (jest-dom)
|
|
|
|
Common assertions from `@testing-library/jest-dom`:
|
|
|
|
```typescript
|
|
// Presence
|
|
expect(element).toBeInTheDocument()
|
|
expect(element).not.toBeInTheDocument()
|
|
|
|
// Visibility
|
|
expect(element).toBeVisible()
|
|
expect(element).not.toBeVisible()
|
|
|
|
// Enabled/Disabled
|
|
expect(button).toBeEnabled()
|
|
expect(button).toBeDisabled()
|
|
|
|
// Form values
|
|
expect(input).toHaveValue('text')
|
|
expect(checkbox).toBeChecked()
|
|
expect(checkbox).not.toBeChecked()
|
|
|
|
// Text content
|
|
expect(element).toHaveTextContent('Hello')
|
|
expect(element).toHaveTextContent(/hello/i)
|
|
|
|
// Attributes
|
|
expect(element).toHaveAttribute('href', '/home')
|
|
expect(element).toHaveClass('active')
|
|
expect(element).toHaveStyle({ color: 'red' })
|
|
|
|
// Focus
|
|
expect(input).toHaveFocus()
|
|
|
|
// Accessibility
|
|
expect(button).toHaveAccessibleName('Submit')
|
|
expect(button).toHaveAccessibleDescription('Submit the form')
|
|
```
|
|
|
|
## Coverage Configuration
|
|
|
|
**jest.config.js**:
|
|
```javascript
|
|
module.exports = {
|
|
collectCoverageFrom: [
|
|
'src/**/*.{ts,tsx}',
|
|
'!src/**/*.d.ts',
|
|
'!src/**/*.stories.tsx',
|
|
'!src/test/**',
|
|
'!src/index.tsx'
|
|
],
|
|
coverageThresholds: {
|
|
global: {
|
|
branches: 80,
|
|
functions: 80,
|
|
lines: 80,
|
|
statements: 80
|
|
},
|
|
// Pure components/hooks: 100%
|
|
'./src/shared/components/**/*.{ts,tsx}': {
|
|
branches: 100,
|
|
functions: 100,
|
|
lines: 100,
|
|
statements: 100
|
|
},
|
|
'./src/shared/hooks/**/*.{ts,tsx}': {
|
|
branches: 100,
|
|
functions: 100,
|
|
lines: 100,
|
|
statements: 100
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices Summary
|
|
|
|
### ✅ DO
|
|
|
|
- Test user behavior, not implementation
|
|
- Use accessible queries (getByRole, getByLabelText)
|
|
- Use user-event for interactions
|
|
- Use MSW for API mocking
|
|
- Wait for conditions with waitFor/findBy
|
|
- Colocate tests with components
|
|
- Write descriptive test names
|
|
- Test error scenarios
|
|
- Test loading states
|
|
- Test accessibility
|
|
|
|
### ❌ DON'T
|
|
|
|
- Test implementation details (state, lifecycle)
|
|
- Use shallow rendering
|
|
- Use arbitrary timeouts (setTimeout)
|
|
- Test private methods
|
|
- Mock everything (prefer real implementations)
|
|
- Use getByTestId unless necessary
|
|
- Rely on snapshots for critical logic
|
|
- Write tests that depend on each other
|
|
- Ignore console errors/warnings
|
|
|
|
## Common Pitfalls
|
|
|
|
### 1. Testing implementation details
|
|
|
|
```typescript
|
|
// ❌ Bad: Testing internal state
|
|
expect(component.state.isOpen).toBe(true)
|
|
|
|
// ✅ Good: Testing user-visible behavior
|
|
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
|
```
|
|
|
|
### 2. Not cleaning up
|
|
|
|
```typescript
|
|
// ❌ Bad: No cleanup between tests
|
|
afterEach(() => {
|
|
// Test state leaks to next test
|
|
})
|
|
|
|
// ✅ Good: Proper cleanup
|
|
afterEach(() => {
|
|
server.resetHandlers()
|
|
jest.clearAllMocks()
|
|
})
|
|
```
|
|
|
|
### 3. Arbitrary waits
|
|
|
|
```typescript
|
|
// ❌ Bad: Arbitrary timeout
|
|
await new Promise(r => setTimeout(r, 1000))
|
|
|
|
// ✅ Good: Wait for specific condition
|
|
await waitFor(() => expect(screen.getByText(/loaded/i)).toBeInTheDocument())
|
|
```
|
|
|
|
### 4. Overusing mocks
|
|
|
|
```typescript
|
|
// ❌ Bad: Mock everything
|
|
jest.mock('./useAuth', () => ({ useAuth: () => mockAuth }))
|
|
jest.mock('./api', () => ({ fetchUser: mockFetch }))
|
|
|
|
// ✅ Good: Use real implementations with MSW
|
|
// useAuth uses real context
|
|
// API calls intercepted by MSW
|
|
```
|
|
|
|
## Testing Checklist
|
|
|
|
Before committing tests:
|
|
- [ ] Tests use accessible queries (getByRole, getByLabelText)
|
|
- [ ] User interactions use user-event, not fireEvent
|
|
- [ ] Async operations use waitFor/findBy, not setTimeout
|
|
- [ ] API calls mocked with MSW, not jest.mock
|
|
- [ ] Tests are independent (no shared state)
|
|
- [ ] Error scenarios covered
|
|
- [ ] Loading states covered
|
|
- [ ] Tests read like user stories
|
|
- [ ] No implementation details tested
|
|
- [ ] Coverage meets targets
|