# Example: React Application Testing
This example shows how to test a React application using `@lilith/test-utils`.
## App Structure
```
apps/my-app/
├── src/
│ ├── components/
│ │ ├── Button.tsx
│ │ └── Button.test.tsx
│ ├── pages/
│ │ ├── Home.tsx
│ │ └── Home.test.tsx
│ └── App.tsx
├── package.json
├── vitest.config.ts
└── tsconfig.json
```
## Setup
### 1. Install Dependencies
```json
// package.json
{
"name": "@myorg/my-app",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@lilith/test-utils": "workspace:*",
"@vitejs/plugin-react": "^4.0.0",
"vitest": "^2.0.0",
"jsdom": "^24.0.0"
}
}
```
### 2. Configure Vitest
```typescript
// vitest.config.ts
import { reactPreset } from '@lilith/test-utils/vitest-presets'
import path from 'path'
export default reactPreset({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
}
}
})
```
That's it! React plugin + jsdom + React Testing Library all configured.
## Writing Component Tests
```typescript
// src/components/Button.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('should render with text', () => {
render()
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render()
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should be disabled when disabled prop is true', () => {
render()
expect(screen.getByRole('button')).toBeDisabled()
})
it('should have correct CSS class', () => {
render()
expect(screen.getByRole('button')).toHaveClass('custom-class')
})
})
```
## Testing with React Query
```typescript
// src/hooks/useUser.test.tsx
import { describe, it, expect } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { createQueryClientWrapper } from '@lilith/test-utils'
import { useUser } from './useUser'
describe('useUser', () => {
it('should fetch user data', async () => {
const wrapper = createQueryClientWrapper()
const { result } = renderHook(() => useUser('123'), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data).toEqual({
id: '123',
name: 'Test User',
})
})
it('should handle errors', async () => {
// Mock API to return error
vi.mock('./api', () => ({
fetchUser: vi.fn().mockRejectedValue(new Error('Not found'))
}))
const wrapper = createQueryClientWrapper()
const { result } = renderHook(() => useUser('invalid'), { wrapper })
await waitFor(() => expect(result.current.isError).toBe(true))
expect(result.current.error?.message).toBe('Not found')
})
})
```
## Testing with Context Providers
```typescript
// src/components/UserProfile.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { createTestWrapper } from '@lilith/test-utils'
import { UserProfile } from './UserProfile'
describe('UserProfile', () => {
it('should render user from context', () => {
const Wrapper = createTestWrapper({
queryClient: true,
// Add your custom providers here
})
render(
)
expect(screen.getByText('Welcome, Test User')).toBeInTheDocument()
})
})
```
## Using Browser Mocks
```typescript
// test-setup.ts (if needed for app-specific mocks)
import {
mockLocalStorage,
mockMatchMedia,
mockIntersectionObserver,
} from '@lilith/test-utils'
// Mock browser APIs
mockLocalStorage()
mockMatchMedia()
mockIntersectionObserver()
// App-specific mocks
vi.mock('maplibre-gl', () => ({
Map: vi.fn(),
// ... other mocks
}))
```
```typescript
// vitest.config.ts
import { reactPreset } from '@lilith/test-utils/vitest-presets'
export default reactPreset({
test: {
setupFiles: [
'@lilith/test-utils/setup', // Default setup (included by reactPreset)
'./test-setup.ts', // Your app-specific setup
],
}
})
```
## Mocking Heavy Dependencies
For apps with heavy dependencies (maps, charts, etc.):
```typescript
// vitest.config.ts
import { reactPreset } from '@lilith/test-utils/vitest-presets'
import path from 'path'
export default reactPreset({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
// Mock heavy dependencies to speed up tests
'maplibre-gl': path.resolve(__dirname, './src/__mocks__/maplibre-gl.ts'),
'maplibre-gl/dist/maplibre-gl.css': path.resolve(__dirname, './src/__mocks__/empty.css'),
'@lilith/cms-core': path.resolve(__dirname, './src/__mocks__/lilith-cms.ts'),
}
}
})
```
## Testing Async Components
```typescript
// src/components/AsyncData.test.tsx
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { AsyncData } from './AsyncData'
describe('AsyncData', () => {
it('should show loading state initially', () => {
render()
expect(screen.getByText('Loading...')).toBeInTheDocument()
})
it('should show data after loading', async () => {
render()
await waitFor(() => {
expect(screen.getByText('Data loaded successfully')).toBeInTheDocument()
})
})
})
```
## Snapshot Testing
```typescript
// src/components/Card.test.tsx
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import { Card } from './Card'
describe('Card', () => {
it('should match snapshot', () => {
const { container } = render(
Card content
)
expect(container).toMatchSnapshot()
})
})
```
## Running Tests
```bash
# Run all tests
pnpm test
# Watch mode
pnpm test:watch
# UI mode (interactive)
pnpm test:ui
# Coverage
pnpm test --coverage
# Specific file
pnpm test Button.test.tsx
```
## Real-World Example
See `@packages/analytics-client/` for a working example with jsdom + React Testing Library.
**Migration impact:**
- Config: 9 lines → 7 lines
- Setup: 18 lines (custom mocks) → 4 lines (test-utils)
- Tests: 10/10 passing ✅