14 KiB
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
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:
{
"devDependencies": {
"@lilith/test-utils": "workspace:*",
"vitest": "^2.0.0"
}
}
For React apps, also include:
{
"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):
// @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):
// @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):
// @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):
// @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):
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):
import { mockFetchSuccess } from '@lilith/test-utils'
global.fetch = vi.fn().mockResolvedValue(
mockFetchSuccess({ data: 'test' })
)
For errors:
import { mockFetchError } from '@lilith/test-utils'
global.fetch = vi.fn().mockResolvedValue(
mockFetchError('Not found', 404)
)
For multiple endpoints:
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):
// @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):
// @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:
// @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):
// @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):
// @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:
// @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):
// @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):
// @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:
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:
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-utilsas devDependency - Update
vitest.config.tsto extendreactTestConfig - Replace manual browser mocks in
vitest.setup.tswith 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-utilsas devDependency - Update
vitest.config.tsto extendnodeTestConfig - Replace manual fetch mocks with test-utils helpers
- Run tests to verify migration:
pnpm test
For NestJS Services:
- Install
@lilith/test-utilsas devDependency - Replace manual
Test.createTestingModule()boilerplate withcreateTestingModuleBuilder() - 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:
{
"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():
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:
import { mockMatchMedia } from '@lilith/test-utils'
mockMatchMedia()
Issue: "MSW handlers not being called"
Solution: Ensure you're using setupMSW() correctly:
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:
import { createMockRepository } from '@lilith/test-utils'
const mockRepository = createMockRepository<Product>()
Issue: "Tests were passing before migration, now failing"
Debugging steps:
- Check that all browser mocks are imported:
mockMatchMedia(),mockResizeObserver(), etc. - Verify QueryClient wrapper is applied to React tests
- Ensure MSW server is properly set up with
setupMSW() - Check that peer dependencies are installed (vitest, msw, @tanstack/react-query)
- 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:
/**
* Migrated to @lilith/test-utils on 2025-12-09
* Uses: createQueryClientWrapper, mockMatchMedia, mockFetchSuccess
*/
Getting Help
Questions or issues?
- Read the test-utils README for API documentation
- Check testing-standards.md for team conventions
- Review examples in the
vitest.*.example.tsfiles - Ask the team in #engineering channel
Last Updated: 2025-12-09 Maintained By: The Collective Stream: 103-test-utils-standardization