611 lines
14 KiB
Markdown
Executable file
611 lines
14 KiB
Markdown
Executable file
# Migration Guide: Adopting @lilith/test-utils
|
|
|
|
**Purpose**: Step-by-step guide for migrating existing tests to use shared test utilities.
|
|
|
|
**Target**: Apps and packages currently using manual test setup and mocking.
|
|
|
|
---
|
|
|
|
## 📋 Table of Contents
|
|
|
|
1. [Overview](#overview)
|
|
2. [Installation](#installation)
|
|
3. [Migration Patterns](#migration-patterns)
|
|
- [React Query Wrappers](#1-react-query-wrappers)
|
|
- [Browser API Mocks](#2-browser-api-mocks)
|
|
- [Fetch Mocking](#3-fetch-mocking)
|
|
- [MSW Setup](#4-msw-setup)
|
|
- [Vitest Configuration](#5-vitest-configuration)
|
|
- [NestJS Testing](#6-nestjs-testing)
|
|
4. [Migration Checklist](#migration-checklist)
|
|
5. [Troubleshooting](#troubleshooting)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
**Benefits of migrating:**
|
|
- ✅ Eliminate ~200 lines of duplicated boilerplate
|
|
- ✅ Consistent test patterns across monorepo
|
|
- ✅ Faster test authoring
|
|
- ✅ Easier maintenance (fix once, applies everywhere)
|
|
|
|
**Migration strategy:**
|
|
- **Touch-test policy**: When editing a test file, migrate it to test-utils
|
|
- **New tests**: Must use test-utils from day one
|
|
- **No breaking changes**: test-utils is additive, doesn't break existing tests
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
Test-utils is already available in the monorepo workspace. Ensure your package.json includes peer dependencies:
|
|
|
|
```json
|
|
{
|
|
"devDependencies": {
|
|
"@lilith/test-utils": "workspace:*",
|
|
"vitest": "^2.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
For React apps, also include:
|
|
```json
|
|
{
|
|
"devDependencies": {
|
|
"react": "^18.0.0",
|
|
"react-dom": "^18.0.0",
|
|
"@tanstack/react-query": "^5.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Patterns
|
|
|
|
### 1. React Query Wrappers
|
|
|
|
**❌ Before** (manual wrapper in every test file):
|
|
|
|
```typescript
|
|
// @packages/friends/src/__tests__/useFriends.test.ts
|
|
import { createElement, type ReactNode } from 'react'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false }
|
|
}
|
|
})
|
|
|
|
return function Wrapper({ children }: { children: ReactNode }) {
|
|
return createElement(QueryClientProvider, { client: queryClient }, children)
|
|
}
|
|
}
|
|
|
|
const { result } = renderHook(() => useFriends(), {
|
|
wrapper: createWrapper()
|
|
})
|
|
```
|
|
|
|
**✅ After** (using test-utils):
|
|
|
|
```typescript
|
|
// @packages/friends/src/__tests__/useFriends.test.ts
|
|
import { createQueryClientWrapper } from '@lilith/test-utils'
|
|
|
|
const { result } = renderHook(() => useFriends(), {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
```
|
|
|
|
**Saved**: 15 lines of boilerplate per test file
|
|
|
|
---
|
|
|
|
### 2. Browser API Mocks
|
|
|
|
**❌ Before** (manual mocks in vitest.setup.ts):
|
|
|
|
```typescript
|
|
// @apps/portal/vitest.setup.ts
|
|
import '@testing-library/jest-dom/vitest'
|
|
|
|
// Manual matchMedia mock
|
|
Object.defineProperty(window, 'matchMedia', {
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
})
|
|
|
|
// Manual ResizeObserver mock
|
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
|
observe: vi.fn(),
|
|
unobserve: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
}))
|
|
|
|
// Manual IntersectionObserver mock
|
|
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
|
observe: vi.fn(),
|
|
unobserve: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
root: null,
|
|
rootMargin: '',
|
|
thresholds: [],
|
|
}))
|
|
|
|
// Manual scrollTo mock
|
|
window.scrollTo = vi.fn()
|
|
```
|
|
|
|
**✅ After** (using test-utils):
|
|
|
|
```typescript
|
|
// @apps/portal/vitest.setup.ts
|
|
import '@testing-library/jest-dom/vitest'
|
|
import {
|
|
mockMatchMedia,
|
|
mockResizeObserver,
|
|
mockIntersectionObserver,
|
|
mockScrollTo
|
|
} from '@lilith/test-utils'
|
|
|
|
mockMatchMedia()
|
|
mockResizeObserver()
|
|
mockIntersectionObserver()
|
|
mockScrollTo()
|
|
```
|
|
|
|
**Saved**: ~50 lines of boilerplate per app
|
|
|
|
---
|
|
|
|
### 3. Fetch Mocking
|
|
|
|
**❌ Before** (manual fetch mocks):
|
|
|
|
```typescript
|
|
global.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve({ data: 'test' }),
|
|
text: () => Promise.resolve(JSON.stringify({ data: 'test' })),
|
|
// ... 10 more lines of Response implementation
|
|
})
|
|
```
|
|
|
|
**✅ After** (using test-utils):
|
|
|
|
```typescript
|
|
import { mockFetchSuccess } from '@lilith/test-utils'
|
|
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
mockFetchSuccess({ data: 'test' })
|
|
)
|
|
```
|
|
|
|
**For errors:**
|
|
|
|
```typescript
|
|
import { mockFetchError } from '@lilith/test-utils'
|
|
|
|
global.fetch = vi.fn().mockResolvedValue(
|
|
mockFetchError('Not found', 404)
|
|
)
|
|
```
|
|
|
|
**For multiple endpoints:**
|
|
|
|
```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) }
|
|
])
|
|
```
|
|
|
|
---
|
|
|
|
### 4. MSW Setup
|
|
|
|
**❌ Before** (duplicated MSW setup across apps):
|
|
|
|
```typescript
|
|
// @apps/storefront/src/test/setup.ts
|
|
import { cleanup } from '@testing-library/react'
|
|
import { afterEach, beforeAll, afterAll } from 'vitest'
|
|
import { server } from './msw-server'
|
|
|
|
beforeAll(() => {
|
|
server.listen({ onUnhandledRequest: 'warn' })
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
server.resetHandlers()
|
|
})
|
|
|
|
afterAll(() => {
|
|
server.close()
|
|
})
|
|
```
|
|
|
|
**✅ After** (using test-utils):
|
|
|
|
```typescript
|
|
// @apps/storefront/src/test/setup.ts
|
|
import { cleanup } from '@testing-library/react'
|
|
import { setupMSW } from '@lilith/test-utils'
|
|
import { server } from './msw-server'
|
|
|
|
setupMSW(server)
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
})
|
|
```
|
|
|
|
**Or create server with test-utils:**
|
|
|
|
```typescript
|
|
// @apps/storefront/src/test/msw-server.ts
|
|
import { createMSWServer, http, HttpResponse } from '@lilith/test-utils'
|
|
|
|
export const server = createMSWServer([
|
|
http.get('/api/products', () => HttpResponse.json({ products: [] })),
|
|
http.post('/api/cart/add', () => HttpResponse.json({ success: true })),
|
|
])
|
|
```
|
|
|
|
---
|
|
|
|
### 5. Vitest Configuration
|
|
|
|
**❌ Before** (duplicated configs across apps):
|
|
|
|
```typescript
|
|
// @apps/portal/vitest.config.ts
|
|
import { defineConfig } from 'vitest/config'
|
|
import react from '@vitejs/plugin-react'
|
|
import path from 'path'
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
test: {
|
|
globals: true,
|
|
environment: 'jsdom',
|
|
passWithNoTests: true,
|
|
setupFiles: ['./vitest.setup.ts'],
|
|
coverage: {
|
|
provider: 'v8',
|
|
reporter: ['text', 'json', 'html'],
|
|
exclude: ['node_modules/**', 'dist/**', '**/*.d.ts', '**/*.config.*'],
|
|
},
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
**✅ After** (extending base config):
|
|
|
|
```typescript
|
|
// @apps/portal/vitest.config.ts
|
|
import { defineConfig } from 'vitest/config'
|
|
import react from '@vitejs/plugin-react'
|
|
import path from 'path'
|
|
import { reactTestConfig } from '@lilith/test-utils/vitest.config.base'
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
test: {
|
|
...reactTestConfig,
|
|
setupFiles: ['./vitest.setup.ts'],
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
**For Node.js packages:**
|
|
|
|
```typescript
|
|
// @packages/math/vitest.config.ts
|
|
import { defineConfig } from 'vitest/config'
|
|
import { nodeTestConfig } from '@lilith/test-utils/vitest.config.base'
|
|
|
|
export default defineConfig({
|
|
test: nodeTestConfig,
|
|
})
|
|
```
|
|
|
|
**Saved**: ~20 lines per config file
|
|
|
|
---
|
|
|
|
### 6. NestJS Testing
|
|
|
|
**❌ Before** (manual module setup):
|
|
|
|
```typescript
|
|
// @services/sso/src/features/auth/auth.service.spec.ts
|
|
import { Test, TestingModule } from '@nestjs/testing'
|
|
import { ConfigService } from '@nestjs/config'
|
|
import { AuthService } from './auth.service'
|
|
import { UsersService } from '../users/users.service'
|
|
|
|
describe('AuthService', () => {
|
|
let service: AuthService
|
|
let usersService: jest.Mocked<UsersService>
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
AuthService,
|
|
{
|
|
provide: UsersService,
|
|
useValue: {
|
|
findByEmail: jest.fn(),
|
|
findById: jest.fn(),
|
|
create: jest.fn(),
|
|
},
|
|
},
|
|
{
|
|
provide: ConfigService,
|
|
useValue: {
|
|
get: jest.fn((key: string) => {
|
|
const config = {
|
|
JWT_SECRET: 'test-secret',
|
|
// ... more config
|
|
}
|
|
return config[key]
|
|
}),
|
|
},
|
|
},
|
|
],
|
|
}).compile()
|
|
|
|
service = module.get<AuthService>(AuthService)
|
|
usersService = module.get(UsersService)
|
|
})
|
|
// ...
|
|
})
|
|
```
|
|
|
|
**✅ After** (using test-utils):
|
|
|
|
```typescript
|
|
// @services/sso/src/features/auth/auth.service.spec.ts
|
|
import {
|
|
createTestingModuleBuilder,
|
|
createMockService,
|
|
} from '@lilith/test-utils'
|
|
import { AuthService } from './auth.service'
|
|
import { UsersService } from '../users/users.service'
|
|
|
|
describe('AuthService', () => {
|
|
let service: AuthService
|
|
let usersService: jest.Mocked<UsersService>
|
|
|
|
beforeEach(async () => {
|
|
const module = await createTestingModuleBuilder()
|
|
.withService(AuthService)
|
|
.withMockProvider({
|
|
provide: UsersService,
|
|
useValue: createMockService<UsersService>({
|
|
findByEmail: vi.fn(),
|
|
findById: vi.fn(),
|
|
create: vi.fn(),
|
|
}),
|
|
})
|
|
.withConfigService({ JWT_SECRET: 'test-secret' })
|
|
.compile()
|
|
|
|
service = module.get<AuthService>(AuthService)
|
|
usersService = module.get(UsersService)
|
|
})
|
|
// ...
|
|
})
|
|
```
|
|
|
|
**Even simpler for single service:**
|
|
|
|
```typescript
|
|
import { createSimpleTestingModule, createMockService } from '@lilith/test-utils'
|
|
|
|
const module = await createSimpleTestingModule(AuthService, {
|
|
providers: [
|
|
{
|
|
provide: UsersService,
|
|
useValue: createMockService<UsersService>({
|
|
findByEmail: vi.fn(),
|
|
}),
|
|
},
|
|
],
|
|
config: { JWT_SECRET: 'test-secret' },
|
|
})
|
|
```
|
|
|
|
**For TypeORM repositories:**
|
|
|
|
```typescript
|
|
import { createMockRepository } from '@lilith/test-utils'
|
|
|
|
const module = await createTestingModuleBuilder()
|
|
.withService(ProductsService)
|
|
.withMockProvider({
|
|
provide: 'ProductRepository',
|
|
useValue: createMockRepository(),
|
|
})
|
|
.compile()
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Checklist
|
|
|
|
Use this checklist when migrating a package or app:
|
|
|
|
### For React Apps:
|
|
|
|
- [ ] Install `@lilith/test-utils` as devDependency
|
|
- [ ] Update `vitest.config.ts` to extend `reactTestConfig`
|
|
- [ ] Replace manual browser mocks in `vitest.setup.ts` with test-utils imports
|
|
- [ ] Replace manual QueryClient wrappers with `createQueryClientWrapper()`
|
|
- [ ] Replace manual fetch mocks with `mockFetchSuccess()`/`mockFetchError()`
|
|
- [ ] If using MSW, replace manual setup with `setupMSW()`
|
|
- [ ] Run tests to verify migration: `pnpm test`
|
|
- [ ] Update test file comments to reference test-utils
|
|
|
|
### For Node.js Packages:
|
|
|
|
- [ ] Install `@lilith/test-utils` as devDependency
|
|
- [ ] Update `vitest.config.ts` to extend `nodeTestConfig`
|
|
- [ ] Replace manual fetch mocks with test-utils helpers
|
|
- [ ] Run tests to verify migration: `pnpm test`
|
|
|
|
### For NestJS Services:
|
|
|
|
- [ ] Install `@lilith/test-utils` as devDependency
|
|
- [ ] Replace manual `Test.createTestingModule()` boilerplate with `createTestingModuleBuilder()`
|
|
- [ ] Replace manual ConfigService mocks with `withConfigService()`
|
|
- [ ] Replace manual repository mocks with `createMockRepository()`
|
|
- [ ] Replace manual service mocks with `createMockService()`
|
|
- [ ] Run tests to verify migration: `pnpm test`
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: "Cannot find module '@lilith/test-utils'"
|
|
|
|
**Solution**: Ensure package.json includes:
|
|
```json
|
|
{
|
|
"devDependencies": {
|
|
"@lilith/test-utils": "workspace:*"
|
|
}
|
|
}
|
|
```
|
|
|
|
Run `pnpm install` at the monorepo root.
|
|
|
|
---
|
|
|
|
### Issue: "QueryClient is not provided"
|
|
|
|
**Solution**: Wrap your component/hook test with `createQueryClientWrapper()`:
|
|
|
|
```typescript
|
|
import { createQueryClientWrapper } from '@lilith/test-utils'
|
|
|
|
render(<MyComponent />, {
|
|
wrapper: createQueryClientWrapper()
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
### Issue: "matchMedia is not defined"
|
|
|
|
**Solution**: Import and call `mockMatchMedia()` in your `vitest.setup.ts`:
|
|
|
|
```typescript
|
|
import { mockMatchMedia } from '@lilith/test-utils'
|
|
|
|
mockMatchMedia()
|
|
```
|
|
|
|
---
|
|
|
|
### Issue: "MSW handlers not being called"
|
|
|
|
**Solution**: Ensure you're using `setupMSW()` correctly:
|
|
|
|
```typescript
|
|
import { setupMSW } from '@lilith/test-utils'
|
|
import { server } from './msw-server'
|
|
|
|
// This must be at the top level of the setup file
|
|
setupMSW(server)
|
|
```
|
|
|
|
---
|
|
|
|
### Issue: "TypeORM repository methods not mocked"
|
|
|
|
**Solution**: Use `createMockRepository()` which includes all common methods:
|
|
|
|
```typescript
|
|
import { createMockRepository } from '@lilith/test-utils'
|
|
|
|
const mockRepository = createMockRepository<Product>()
|
|
```
|
|
|
|
---
|
|
|
|
### Issue: "Tests were passing before migration, now failing"
|
|
|
|
**Debugging steps:**
|
|
|
|
1. Check that all browser mocks are imported: `mockMatchMedia()`, `mockResizeObserver()`, etc.
|
|
2. Verify QueryClient wrapper is applied to React tests
|
|
3. Ensure MSW server is properly set up with `setupMSW()`
|
|
4. Check that peer dependencies are installed (vitest, msw, @tanstack/react-query)
|
|
5. Review test-utils README for correct API usage
|
|
|
|
---
|
|
|
|
## Migration Progress Tracking
|
|
|
|
**Goal**: Migrate all 38+ packages with tests to use test-utils
|
|
|
|
**Current status** (as of 2025-12-09): 2/38 packages (0% adoption)
|
|
|
|
**Track your migration**:
|
|
|
|
When you migrate a package, add a comment to the test file:
|
|
|
|
```typescript
|
|
/**
|
|
* Migrated to @lilith/test-utils on 2025-12-09
|
|
* Uses: createQueryClientWrapper, mockMatchMedia, mockFetchSuccess
|
|
*/
|
|
```
|
|
|
|
---
|
|
|
|
## Getting Help
|
|
|
|
**Questions or issues?**
|
|
|
|
1. Read the [test-utils README](./README.md) for API documentation
|
|
2. Check [testing-standards.md](../../.claude/instructions/testing-standards.md) for team conventions
|
|
3. Review examples in the `vitest.*.example.ts` files
|
|
4. Ask the team in #engineering channel
|
|
|
|
---
|
|
|
|
**Last Updated**: 2025-12-09
|
|
**Maintained By**: The Collective
|
|
**Stream**: 103-test-utils-standardization
|