638 lines
14 KiB
Markdown
Executable file
638 lines
14 KiB
Markdown
Executable file
# @lilith/test-utils
|
|
|
|
**Shared testing utilities for lilith-platform monorepo.**
|
|
|
|
This package provides standardized mocks, helpers, and test configuration to eliminate duplication across apps, packages, and services.
|
|
|
|
---
|
|
|
|
## 📦 Installation
|
|
|
|
```bash
|
|
pnpm add -D @lilith/test-utils
|
|
```
|
|
|
|
**Peer dependencies:**
|
|
- `vitest ^2.0.0`
|
|
- `react ^18.0.0` (for React testing utilities)
|
|
- `react-dom ^18.0.0` (for React testing utilities)
|
|
- `@tanstack/react-query ^5.0.0` (for React Query utilities)
|
|
|
|
---
|
|
|
|
## 🧪 Quick Start
|
|
|
|
### Basic Unit Test (No React)
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
|
|
describe('myFunction', () => {
|
|
it('should work correctly', () => {
|
|
expect(myFunction(1, 2)).toBe(3)
|
|
})
|
|
})
|
|
```
|
|
|
|
### React Component Test
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
import { render, screen } from '@testing-library/react'
|
|
import { createQueryClientWrapper } from '@lilith/test-utils'
|
|
import { MyComponent } from './MyComponent'
|
|
|
|
describe('MyComponent', () => {
|
|
it('should render correctly', () => {
|
|
render(<MyComponent />, {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
|
|
expect(screen.getByText('Hello')).toBeInTheDocument()
|
|
})
|
|
})
|
|
```
|
|
|
|
### Test with Browser APIs
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { mockLocalStorage, mockMatchMedia } from '@lilith/test-utils'
|
|
|
|
describe('responsive feature', () => {
|
|
beforeEach(() => {
|
|
mockLocalStorage()
|
|
mockMatchMedia(true) // true = matches media query
|
|
})
|
|
|
|
it('should work with mocked APIs', () => {
|
|
// Your test code
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 📚 API Reference
|
|
|
|
### Browser API Mocks
|
|
|
|
Mock common browser APIs that don't exist in jsdom test environment.
|
|
|
|
#### `mockLocalStorage(): Storage`
|
|
|
|
Creates an in-memory localStorage mock.
|
|
|
|
```typescript
|
|
import { mockLocalStorage } from '@lilith/test-utils'
|
|
|
|
beforeEach(() => {
|
|
const storage = mockLocalStorage()
|
|
storage.setItem('key', 'value')
|
|
expect(storage.getItem('key')).toBe('value')
|
|
})
|
|
```
|
|
|
|
#### `mockSessionStorage(): Storage`
|
|
|
|
Creates an in-memory sessionStorage mock.
|
|
|
|
```typescript
|
|
import { mockSessionStorage } from '@lilith/test-utils'
|
|
|
|
beforeEach(() => {
|
|
const storage = mockSessionStorage()
|
|
storage.setItem('session-key', 'session-value')
|
|
})
|
|
```
|
|
|
|
#### `mockMatchMedia(defaultMatches?: boolean): void`
|
|
|
|
Mocks `window.matchMedia` for testing responsive components.
|
|
|
|
```typescript
|
|
import { mockMatchMedia } from '@lilith/test-utils'
|
|
|
|
// Desktop viewport
|
|
mockMatchMedia(false)
|
|
|
|
// Mobile viewport
|
|
mockMatchMedia(true)
|
|
```
|
|
|
|
#### `mockIntersectionObserver(): void`
|
|
|
|
Mocks IntersectionObserver API for lazy-loading/infinite scroll tests.
|
|
|
|
```typescript
|
|
import { mockIntersectionObserver } from '@lilith/test-utils'
|
|
|
|
beforeEach(() => {
|
|
mockIntersectionObserver()
|
|
})
|
|
```
|
|
|
|
#### `mockResizeObserver(): void`
|
|
|
|
Mocks ResizeObserver API for responsive component tests.
|
|
|
|
```typescript
|
|
import { mockResizeObserver } from '@lilith/test-utils'
|
|
|
|
beforeEach(() => {
|
|
mockResizeObserver()
|
|
})
|
|
```
|
|
|
|
#### `mockScrollTo(): void`
|
|
|
|
Mocks `window.scrollTo` to prevent errors in jsdom.
|
|
|
|
```typescript
|
|
import { mockScrollTo } from '@lilith/test-utils'
|
|
|
|
beforeEach(() => {
|
|
mockScrollTo()
|
|
})
|
|
```
|
|
|
|
#### `mockBroadcastChannel(): void`
|
|
|
|
Mocks BroadcastChannel API for cross-tab communication tests.
|
|
|
|
```typescript
|
|
import { mockBroadcastChannel } from '@lilith/test-utils'
|
|
|
|
beforeEach(() => {
|
|
mockBroadcastChannel()
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
### React Query Test Utilities
|
|
|
|
Utilities for testing React Query hooks and components.
|
|
|
|
#### `createTestQueryClient(): QueryClient`
|
|
|
|
Creates a pre-configured QueryClient for testing (no retries, no caching).
|
|
|
|
```typescript
|
|
import { createTestQueryClient } from '@lilith/test-utils'
|
|
|
|
const queryClient = createTestQueryClient()
|
|
```
|
|
|
|
**Configuration:**
|
|
- `retry: false` - No automatic retries
|
|
- `gcTime: 0` - No garbage collection delay
|
|
- `staleTime: 0` - Data immediately stale
|
|
|
|
#### `createQueryClientWrapper(queryClient?: QueryClient): FC<{ children: ReactNode }>`
|
|
|
|
Creates a wrapper component with QueryClientProvider for testing hooks.
|
|
|
|
```typescript
|
|
import { renderHook } from '@testing-library/react'
|
|
import { createQueryClientWrapper } from '@lilith/test-utils'
|
|
import { useMyQuery } from './hooks'
|
|
|
|
const { result } = renderHook(() => useMyQuery(), {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
```
|
|
|
|
#### `createTestWrapper(options?: TestWrapperOptions): FC<{ children: ReactNode }>`
|
|
|
|
Creates a comprehensive test wrapper with QueryClient + additional providers.
|
|
|
|
```typescript
|
|
import { render } from '@testing-library/react'
|
|
import { createTestWrapper } from '@lilith/test-utils'
|
|
|
|
const wrapper = createTestWrapper({
|
|
queryClient: customQueryClient, // Optional
|
|
additionalProviders: [ThemeProvider, AuthProvider] // Optional
|
|
})
|
|
|
|
render(<MyComponent />, { wrapper })
|
|
```
|
|
|
|
**Options:**
|
|
```typescript
|
|
interface TestWrapperOptions {
|
|
queryClient?: QueryClient // Custom QueryClient instance
|
|
additionalProviders?: FC<{ children: ReactNode }>[] // Additional context providers
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Fetch Mocking Utilities
|
|
|
|
Utilities for mocking `fetch` API calls.
|
|
|
|
#### `mockFetchSuccess<T>(data: T, status?: number): Response`
|
|
|
|
Creates a successful fetch Response mock.
|
|
|
|
```typescript
|
|
import { mockFetchSuccess } from '@lilith/test-utils'
|
|
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
mockFetchSuccess({ id: 1, name: 'Test' })
|
|
)
|
|
```
|
|
|
|
#### `mockFetchError(message: string, status?: number): Response`
|
|
|
|
Creates an error fetch Response mock.
|
|
|
|
```typescript
|
|
import { mockFetchError } from '@lilith/test-utils'
|
|
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
mockFetchError('Not found', 404)
|
|
)
|
|
```
|
|
|
|
#### `mockFetchSequence(responses: Array<{ url: string | RegExp, response: Response }>): typeof fetch`
|
|
|
|
Creates a fetch mock that responds differently based on URL.
|
|
|
|
```typescript
|
|
import { mockFetchSequence, mockFetchSuccess, mockFetchError } from '@lilith/test-utils'
|
|
|
|
mockFetchSequence([
|
|
{ url: '/api/users', response: mockFetchSuccess([{ id: 1 }]) },
|
|
{ url: /\/api\/posts\/\d+/, response: mockFetchSuccess({ id: 1, title: 'Post' }) },
|
|
{ url: '/api/error', response: mockFetchError('Server error', 500) }
|
|
])
|
|
```
|
|
|
|
#### `createFetchMock(): ReturnType<typeof vi.fn>`
|
|
|
|
Creates a basic fetch mock function.
|
|
|
|
```typescript
|
|
import { createFetchMock } from '@lilith/test-utils'
|
|
|
|
const fetchMock = createFetchMock()
|
|
global.fetch = fetchMock
|
|
|
|
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: 'test' }) })
|
|
```
|
|
|
|
---
|
|
|
|
### Wait Utilities
|
|
|
|
Utilities for handling asynchronous test scenarios.
|
|
|
|
#### `waitForAsync(ms: number): Promise<void>`
|
|
|
|
Wait for a specified duration.
|
|
|
|
```typescript
|
|
import { waitForAsync } from '@lilith/test-utils'
|
|
|
|
await waitForAsync(100) // Wait 100ms
|
|
```
|
|
|
|
#### `waitForCondition(condition: () => boolean | Promise<boolean>, options?: { timeout?: number, interval?: number }): Promise<void>`
|
|
|
|
Wait until a condition is true.
|
|
|
|
```typescript
|
|
import { waitForCondition } from '@lilith/test-utils'
|
|
|
|
await waitForCondition(
|
|
() => document.querySelector('.loaded') !== null,
|
|
{ timeout: 5000, interval: 50 }
|
|
)
|
|
```
|
|
|
|
**Options:**
|
|
- `timeout` - Maximum wait time in ms (default: 5000)
|
|
- `interval` - Check interval in ms (default: 50)
|
|
|
|
---
|
|
|
|
### Test Data Factories
|
|
|
|
Utilities for generating consistent test data.
|
|
|
|
#### `generateId(): string`
|
|
|
|
Generates a random unique ID.
|
|
|
|
```typescript
|
|
import { generateId } from '@lilith/test-utils'
|
|
|
|
const userId = generateId() // "ab3f7e8d91762c3ef"
|
|
```
|
|
|
|
#### `generateDate(daysFromNow?: number): string`
|
|
|
|
Generates an ISO date string.
|
|
|
|
```typescript
|
|
import { generateDate } from '@lilith/test-utils'
|
|
|
|
const today = generateDate() // "2024-12-09T12:34:56.789Z"
|
|
const tomorrow = generateDate(1) // "2024-12-10T12:34:56.789Z"
|
|
const yesterday = generateDate(-1) // "2024-12-08T12:34:56.789Z"
|
|
```
|
|
|
|
#### `generateTime(hours: number, minutes?: number): string`
|
|
|
|
Generates a time string in HH:MM format.
|
|
|
|
```typescript
|
|
import { generateTime } from '@lilith/test-utils'
|
|
|
|
const time = generateTime(14, 30) // "14:30"
|
|
```
|
|
|
|
#### `createFactory<T>(defaults: T): (overrides?: Partial<T>) => T`
|
|
|
|
Creates a factory function for building test objects.
|
|
|
|
```typescript
|
|
import { createFactory } from '@lilith/test-utils'
|
|
|
|
interface User {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
age: number
|
|
}
|
|
|
|
const createUser = createFactory<User>({
|
|
id: '1',
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
age: 25
|
|
})
|
|
|
|
// Use defaults
|
|
const user1 = createUser()
|
|
|
|
// Override specific fields
|
|
const user2 = createUser({ name: 'Amy Smith', age: 30 })
|
|
```
|
|
|
|
---
|
|
|
|
### Setup Utilities
|
|
|
|
#### `setupTests(): void`
|
|
|
|
Global test setup (imported in vitest.setup.ts).
|
|
|
|
```typescript
|
|
import { setupTests } from '@lilith/test-utils'
|
|
|
|
setupTests()
|
|
```
|
|
|
|
#### `cleanupTests(): void`
|
|
|
|
Global test cleanup.
|
|
|
|
```typescript
|
|
import { cleanupTests } from '@lilith/test-utils'
|
|
|
|
afterEach(() => {
|
|
cleanupTests()
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 🏗️ Common Patterns
|
|
|
|
### Testing React Query Hooks
|
|
|
|
```typescript
|
|
import { describe, it, expect, vi } from 'vitest'
|
|
import { renderHook, waitFor } from '@testing-library/react'
|
|
import { createQueryClientWrapper, mockFetchSuccess } from '@lilith/test-utils'
|
|
|
|
describe('useFetchUser', () => {
|
|
it('should fetch user data', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
mockFetchSuccess({ id: 1, name: 'John' })
|
|
)
|
|
|
|
const { result } = renderHook(() => useFetchUser('1'), {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
|
|
expect(result.current.isLoading).toBe(true)
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
expect(result.current.data).toEqual({ id: 1, name: 'John' })
|
|
})
|
|
})
|
|
```
|
|
|
|
### Testing Components with Multiple Providers
|
|
|
|
```typescript
|
|
import { render, screen } from '@testing-library/react'
|
|
import { createTestWrapper } from '@lilith/test-utils'
|
|
import { ThemeProvider } from './theme'
|
|
import { AuthProvider } from './auth'
|
|
|
|
const wrapper = createTestWrapper({
|
|
additionalProviders: [ThemeProvider, AuthProvider]
|
|
})
|
|
|
|
render(<MyComponent />, { wrapper })
|
|
expect(screen.getByText('Authenticated')).toBeInTheDocument()
|
|
```
|
|
|
|
### Testing with Browser API Mocks
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import {
|
|
mockLocalStorage,
|
|
mockMatchMedia,
|
|
mockIntersectionObserver
|
|
} from '@lilith/test-utils'
|
|
|
|
describe('responsive component with storage', () => {
|
|
beforeEach(() => {
|
|
mockLocalStorage()
|
|
mockMatchMedia(true) // Mobile viewport
|
|
mockIntersectionObserver()
|
|
})
|
|
|
|
it('should handle mobile layout with storage', () => {
|
|
// Your test code
|
|
})
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 📝 Vitest Setup File
|
|
|
|
Create `vitest.setup.ts` in your app/package:
|
|
|
|
```typescript
|
|
import '@testing-library/jest-dom/vitest'
|
|
import {
|
|
mockMatchMedia,
|
|
mockIntersectionObserver,
|
|
mockResizeObserver,
|
|
mockScrollTo
|
|
} from '@lilith/test-utils'
|
|
|
|
// Setup common browser mocks
|
|
mockMatchMedia()
|
|
mockIntersectionObserver()
|
|
mockResizeObserver()
|
|
mockScrollTo()
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Migration from Manual Mocks
|
|
|
|
### Before (Manual QueryClient wrapper)
|
|
|
|
```typescript
|
|
// ❌ Duplicated in every test file
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false }
|
|
}
|
|
})
|
|
return function Wrapper({ children }) {
|
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
}
|
|
}
|
|
|
|
const { result } = renderHook(() => useMyHook(), {
|
|
wrapper: createWrapper()
|
|
})
|
|
```
|
|
|
|
### After (Using test-utils)
|
|
|
|
```typescript
|
|
// ✅ Import from shared package
|
|
import { createQueryClientWrapper } from '@lilith/test-utils'
|
|
|
|
const { result } = renderHook(() => useMyHook(), {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
```
|
|
|
|
### Before (Manual browser mocks)
|
|
|
|
```typescript
|
|
// ❌ Duplicated in vitest.setup.ts across apps
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
// ... 10 more lines
|
|
}))
|
|
})
|
|
|
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
observe: vi.fn(),
|
|
unobserve: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}))
|
|
```
|
|
|
|
### After (Using test-utils)
|
|
|
|
```typescript
|
|
// ✅ Import from shared package
|
|
import { mockMatchMedia, mockResizeObserver } from '@lilith/test-utils'
|
|
|
|
mockMatchMedia()
|
|
mockResizeObserver()
|
|
```
|
|
|
|
---
|
|
|
|
## 🔗 Related Documentation
|
|
|
|
- [Testing Standards](../../.claude/instructions/testing-standards.md) - Team testing conventions
|
|
- [Vitest Documentation](https://vitest.dev) - Vitest framework docs
|
|
- [Testing Library](https://testing-library.com/react) - React Testing Library docs
|
|
|
|
---
|
|
|
|
## 📊 Package Statistics
|
|
|
|
**Current adoption**: 2 files (as of 2025-12-09)
|
|
|
|
**Goal**: Standardize testing across 38+ packages with unit tests
|
|
|
|
**Benefits**:
|
|
- ✅ Eliminate ~200 lines of duplicated mock code across apps
|
|
- ✅ Consistent test patterns across monorepo
|
|
- ✅ Faster test authoring (import vs write boilerplate)
|
|
- ✅ Single source of truth for test utilities
|
|
|
|
---
|
|
|
|
## 🐛 Troubleshooting
|
|
|
|
### QueryClient warnings in tests
|
|
|
|
If you see warnings about QueryClient not being provided:
|
|
|
|
```typescript
|
|
// Make sure you wrap with QueryClientProvider
|
|
import { createQueryClientWrapper } from '@lilith/test-utils'
|
|
|
|
render(<MyComponent />, {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
```
|
|
|
|
### Browser API not mocked
|
|
|
|
If you get errors about `matchMedia` or `IntersectionObserver` not being defined:
|
|
|
|
```typescript
|
|
// Add to your vitest.setup.ts
|
|
import { mockMatchMedia, mockIntersectionObserver } from '@lilith/test-utils'
|
|
|
|
mockMatchMedia()
|
|
mockIntersectionObserver()
|
|
```
|
|
|
|
### Fetch is not mocked
|
|
|
|
If your tests make real network requests:
|
|
|
|
```typescript
|
|
import { createFetchMock, mockFetchSuccess } from '@lilith/test-utils'
|
|
|
|
const fetchMock = createFetchMock()
|
|
global.fetch = fetchMock
|
|
|
|
fetchMock.mockResolvedValue(mockFetchSuccess({ data: 'test' }))
|
|
```
|
|
|
|
---
|
|
|
|
**Maintained by**: The Collective
|
|
**Last Updated**: 2025-12-09
|
|
**Stream**: 103-test-utils-standardization
|