Frontend Testing Guide¶
Comprehensive testing documentation for the MCP Gateway Registry React frontend.
Overview¶
The frontend uses a modern testing stack:
| Tool | Version | Purpose |
|---|---|---|
| Vitest | 4.x | Test runner (fast, ESM-native) |
| React Testing Library | 16.x | Component testing (React 19 compatible) |
| testing-library/jest-dom | 6.x | DOM matchers |
| testing-library/user-event | 14.x | User interaction simulation |
| v8 | - | Coverage provider |
Quick Start¶
cd frontend
# Run all tests
npm test
# Watch mode (recommended for development)
npm run test:watch
# With Vitest UI
npm run test:ui
# With coverage
npm run test:coverage
# CI mode (verbose output)
npm run test:ci
Test Structure¶
frontend/
├── tests/
│ ├── components/ # Component tests
│ │ └── Toast.test.tsx
│ ├── hooks/ # Custom hook tests
│ │ └── useSemanticSearch.test.ts
│ ├── unit/ # Unit tests
│ │ ├── constants/ # Constants tests
│ │ │ └── index.test.ts
│ │ └── utils/ # Utility function tests
│ │ ├── filterUtils.test.ts
│ │ ├── dateUtils.test.ts
│ │ └── errorHandler.test.ts
│ └── reports/
│ └── coverage/ # Coverage reports
├── src/test/
│ ├── test-utils.tsx # Custom render with providers
│ └── mocks/
│ └── axios.ts # Axios mock utilities
├── vitest.config.ts # Vitest configuration
└── vitest.setup.ts # Test setup and mocks
Configuration¶
vitest.config.ts¶
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['tests/**/*.{test,spec}.{ts,tsx}'],
exclude: ['**/node_modules/**', '**/build/**'],
isolate: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reportsDirectory: './tests/reports/coverage',
include: ['src/**/*.{ts,tsx}'],
thresholds: {
statements: 8,
branches: 5,
functions: 5,
lines: 8,
},
},
testTimeout: 10000,
hookTimeout: 10000,
retry: process.env.CI ? 1 : 0,
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})
vitest.setup.ts¶
The setup file configures:
- jest-dom matchers: Extended DOM assertions
- localStorage mock: For ThemeContext
- matchMedia mock: For responsive components
- IntersectionObserver mock: For lazy loading
- ResizeObserver mock: For responsive components
- Automatic cleanup: After each test
Test Utilities¶
Custom Render (src/test/test-utils.tsx)¶
Wraps components with necessary providers:
import { render, screen, waitFor } from '../src/test/test-utils'
// Renders with MemoryRouter (not BrowserRouter for test isolation)
// and ThemeProvider
render(<MyComponent />)
// All @testing-library/react exports are re-exported
expect(screen.getByText('Hello')).toBeInTheDocument()
Axios Mocks (src/test/mocks/axios.ts)¶
import {
resetAxiosMocks,
mockAxiosSuccess,
mockAxiosError,
mockAxiosNetworkError,
mockAxiosPending,
mockedAxios
} from '../src/test/mocks/axios'
beforeEach(() => {
resetAxiosMocks()
})
// Mock successful response
mockAxiosSuccess({ users: [] }, 'get')
// Mock error response
mockAxiosError(404, 'Not found', 'get')
// Mock network error
mockAxiosNetworkError('post')
// Mock pending request (for loading states)
const { resolve, reject } = mockAxiosPending<UserData>('get')
// Later: resolve({ name: 'Test' }) or reject(new Error())
Writing Tests¶
Component Tests¶
import { describe, it, expect, vi } from 'vitest'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../../src/test/test-utils'
import { Toast, type ToastType } from '../../src/components/Toast'
describe('Toast', () => {
const defaultProps = {
message: 'Test message',
type: 'success' as ToastType,
onClose: vi.fn(),
}
it('renders message', () => {
render(<Toast {...defaultProps} />)
expect(screen.getByText('Test message')).toBeInTheDocument()
})
it('applies correct styling for type', () => {
render(<Toast {...defaultProps} type="error" />)
const toast = screen.getByRole('alert')
expect(toast).toHaveClass('bg-red-50')
})
it('calls onClose when close button clicked', async () => {
vi.useRealTimers() // userEvent needs real timers
const user = userEvent.setup()
const onClose = vi.fn()
render(<Toast {...defaultProps} onClose={onClose} />)
await user.click(screen.getByRole('button', { name: /close/i }))
expect(onClose).toHaveBeenCalledTimes(1)
vi.useFakeTimers() // restore
})
it('auto-dismisses after timeout', () => {
vi.useFakeTimers()
const onClose = vi.fn()
render(<Toast {...defaultProps} onClose={onClose} autoDismiss={3000} />)
expect(onClose).not.toHaveBeenCalled()
vi.advanceTimersByTime(3000)
expect(onClose).toHaveBeenCalledTimes(1)
})
})
Hook Tests¶
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import axios from 'axios'
import { useSemanticSearch } from '../../src/hooks/useSemanticSearch'
vi.mock('axios')
const mockedAxios = vi.mocked(axios, true)
describe('useSemanticSearch', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
;(mockedAxios.isCancel as unknown) = vi.fn().mockReturnValue(false)
})
afterEach(() => {
vi.useRealTimers()
})
it('returns null results for empty query', () => {
const { result } = renderHook(() => useSemanticSearch(''))
expect(result.current.results).toBeNull()
expect(result.current.loading).toBe(false)
})
it('debounces API calls', async () => {
mockedAxios.post.mockResolvedValueOnce({ data: { results: [] } })
const { rerender } = renderHook(
({ query }) => useSemanticSearch(query),
{ initialProps: { query: '' } }
)
rerender({ query: 'te' })
rerender({ query: 'tes' })
rerender({ query: 'test' })
// API not called yet (debouncing)
expect(mockedAxios.post).not.toHaveBeenCalled()
// After debounce period
await act(async () => {
vi.advanceTimersByTime(300) // DEBOUNCE_MS
await vi.runAllTimersAsync()
})
expect(mockedAxios.post).toHaveBeenCalledTimes(1)
})
})
Utility Tests¶
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { formatTimeSince } from '../../../src/utils/dateUtils'
describe('formatTimeSince', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('returns "just now" for recent times', () => {
const now = new Date()
expect(formatTimeSince(now.toISOString())).toBe('just now')
})
it('formats minutes correctly', () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
expect(formatTimeSince(fiveMinutesAgo.toISOString())).toBe('5 minutes ago')
})
it('handles null input', () => {
expect(formatTimeSince(null)).toBe('Unknown')
})
})
Common Patterns¶
Testing with Fake Timers¶
describe('timer tests', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('advances time', () => {
const callback = vi.fn()
setTimeout(callback, 1000)
vi.advanceTimersByTime(1000)
expect(callback).toHaveBeenCalled()
})
})
Testing Async Operations with Debouncing¶
await act(async () => {
vi.advanceTimersByTime(DEBOUNCE_MS)
await vi.runAllTimersAsync() // Important for async operations
})
Testing with userEvent¶
// userEvent requires real timers
it('handles user input', async () => {
vi.useRealTimers()
const user = userEvent.setup()
render(<Input />)
await user.type(screen.getByRole('textbox'), 'hello')
expect(screen.getByRole('textbox')).toHaveValue('hello')
vi.useFakeTimers() // restore for subsequent tests
})
Mocking axios.isCancel¶
The isCancel function is a type predicate, requiring special handling:
// Type assertion needed
;(mockedAxios.isCancel as unknown) = vi.fn().mockReturnValue(false)
// For cancelled requests
;(mockedAxios.isCancel as unknown) = vi.fn().mockReturnValue(true)
Coverage¶
Thresholds¶
Current thresholds (will increase as coverage improves):
| Metric | Threshold |
|---|---|
| Statements | 8% |
| Branches | 5% |
| Functions | 5% |
| Lines | 8% |
High-Coverage Files¶
| File | Coverage |
|---|---|
constants/index.ts | 100% |
components/Toast.tsx | 100% |
hooks/useSemanticSearch.ts | 100% |
utils/filterUtils.ts | 100% |
utils/errorHandler.ts | 100% |
utils/dateUtils.ts | 88% |
Generating Reports¶
npm run test:coverage
# Reports generated at:
# - tests/reports/coverage/index.html (HTML)
# - tests/reports/coverage/lcov.info (LCOV for Codecov)
CI/CD Integration¶
Tests run via GitHub Actions (.github/workflows/frontend-test.yml):
name: Frontend Tests
on:
push:
branches: [main, develop]
paths:
- 'frontend/**'
pull_request:
branches: [main, develop]
paths:
- 'frontend/**'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
- run: npm run test:ci
- uses: codecov/codecov-action@v4
with:
files: frontend/tests/reports/coverage/lcov.info
flags: frontend
Troubleshooting¶
Tests hang or timeout¶
Cause: userEvent with fake timers
Solution: Switch to real timers for userEvent tests:
TypeScript errors with mocks¶
Cause: Type predicates can't be directly mocked
Solution: Use type assertion:
Coverage too low¶
Solution: Check uncovered files:
ThemeContext errors¶
Cause: localStorage not available in jsdom
Solution: Ensure vitest.setup.ts includes localStorage mock (already configured)
Best Practices¶
- Use custom render: Always use
renderfromtest-utils.tsxfor provider wrapping - Query by role: Prefer
getByRolefor accessibility testing - Avoid implementation details: Test behavior, not implementation
- Use data-testid sparingly: Only when no semantic query works
- Clean up mocks: Call
vi.clearAllMocks()inbeforeEach - Isolate tests: Each test should be independent
- Test edge cases: null, undefined, empty arrays, boundary values