Testing Guide
Comprehensive testing strategy and guidelines for Constellation.
Testing Stack
- Test Runner: Vitest 4.0.17
- React Testing: @testing-library/react 16.3.1
- User Interactions: @testing-library/user-event 14.6.1
- API Mocking: MSW 2.12.7
- Coverage: @vitest/coverage-v8 4.0.17
Running Tests
Development Mode
# Watch mode - re-run on file changes
bun test
# Watch mode with UI
bun test:ui
# Single run
bun test:run
# Generate coverage report
bun test:coverage
# Coverage for all files
bun test:coverage-all
Coverage Reports
View HTML coverage report:
bun test:coverage
open coverage/index.html
Test File Organization
src/
โโโ components/
โ โโโ ui/
โ โ โโโ Button.tsx
โ โ โโโ Button.test.tsx
โ โโโ ...
โโโ hooks/
โ โโโ use-mobile.tsx
โ โโโ use-mobile.test.tsx
โโโ lib/
โ โโโ utils.ts
โ โโโ utils.test.ts
โโโ test/
โโโ mocks/
โ โโโ server.ts # MSW server setup
โ โโโ handlers.ts # API handlers
โโโ test-utils.tsx # Custom render
โโโ test-helpers.ts # Utilities
Unit Tests
Testing Functions
// src/lib/utils.ts
export function formatDate(date: Date, locale: string = 'en-US'): string {
return date.toLocaleDateString(locale);
}
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2024-01-15T00:00:00Z');
const result = formatDate(date);
expect(result).toContain('2024');
expect(result).toContain('1');
expect(result).toContain('15');
});
it('should use provided locale', () => {
const date = new Date('2024-01-15');
const result = formatDate(date, 'fr-FR');
expect(result).toBeDefined();
});
it('should handle edge cases', () => {
const date = new Date(0); // Unix epoch
expect(() => formatDate(date)).not.toThrow();
});
});
Testing Constants
// src/lib/constants.ts
export const MAX_ATTEMPTS = 3;
export const API_TIMEOUT = 30000;
// src/lib/constants.test.ts
import { describe, it, expect } from 'vitest';
import { MAX_ATTEMPTS, API_TIMEOUT } from './constants';
describe('Constants', () => {
it('should have correct values', () => {
expect(MAX_ATTEMPTS).toBe(3);
expect(API_TIMEOUT).toBe(30000);
});
});
Component Tests
Basic Component Test
// src/components/common/Button.tsx
export function Button({ onClick, children }: ButtonProps) {
return <button onClick={onClick}>{children}</button>;
}
// src/components/common/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('should render with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('should call onClick handler', async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('should be disabled when prop is set', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
Testing with State
// src/components/Counter.tsx
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter', () => {
it('should increment count', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
it('should increment multiple times', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button');
await user.click(button);
await user.click(button);
await user.click(button);
expect(screen.getByText('Count: 3')).toBeInTheDocument();
});
});
Hook Tests
Testing Custom Hooks
// src/hooks/use-counter.ts
export function useCounter(initial: number = 0) {
const [count, setCount] = useState(initial);
return {
count,
increment: () => setCount(c => c + 1),
decrement: () => setCount(c => c - 1),
reset: () => setCount(initial),
};
}
// src/hooks/use-counter.test.ts
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './use-counter';
describe('useCounter', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('should increment', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('should reset', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
Testing Hooks with Async Operations
// src/hooks/use-user-data.ts
export function useUserData(userId: string) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const data = await fetchUser(userId);
setUser(data);
setLoading(false);
})();
}, [userId]);
return { user, loading };
}
// src/hooks/use-user-data.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useUserData } from './use-user-data';
// Mock the fetch function
vi.mock('@/src/services/userService', () => ({
fetchUser: vi.fn(),
}));
describe('useUserData', () => {
it('should load user data', async () => {
vi.mocked(fetchUser).mockResolvedValueOnce({
id: '1',
name: 'John',
});
const { result } = renderHook(() => useUserData('1'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual({ id: '1', name: 'John' });
});
it('should handle different userId', async () => {
vi.mocked(fetchUser).mockResolvedValueOnce({ id: '2', name: 'Jane' });
const { result, rerender } = renderHook(
({ userId }) => useUserData(userId),
{ initialProps: { userId: '1' } }
);
rerender({ userId: '2' });
await waitFor(() => {
expect(result.current.user?.id).toBe('2');
});
});
});
API Mocking with MSW
Setup MSW
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// GET user
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'John Doe',
email: 'john@example.com',
});
}),
// POST create workspace
http.post('/api/workspaces', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '123', ...body }, { status: 201 });
}),
// DELETE workspace
http.delete('/api/workspaces/:id', ({ params }) => {
return HttpResponse.json({ success: true });
}),
// Error handler
http.get('/api/error', () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}),
];
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './src/test/mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Testing API Calls
// src/components/UserProfile.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
it('should fetch and display user', async () => {
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
it('should handle API errors', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
})
);
render(<UserProfile userId="999" />);
await waitFor(() => {
expect(screen.getByText(/not found/i)).toBeInTheDocument();
});
});
});
Testing Best Practices
Test Naming
// โ
GOOD - Clear, descriptive names
describe('calculateDiscount', () => {
it('should return 10% discount for premium users', () => {});
it('should return 0% discount for new users', () => {});
it('should throw error for invalid percentage', () => {});
});
// โ BAD - Vague names
describe('calculateDiscount', () => {
it('works', () => {});
it('test 1', () => {});
it('error handling', () => {});
});
Arrange-Act-Assert Pattern
describe('UserForm', () => {
it('should submit form with valid data', async () => {
// Arrange
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<UserForm onSubmit={handleSubmit} />);
// Act
await user.type(screen.getByLabelText('Name'), 'John');
await user.click(screen.getByRole('button', { name: /submit/i }));
// Assert
expect(handleSubmit).toHaveBeenCalledWith({ name: 'John' });
});
});
Test Isolation
// โ
GOOD - Each test is independent
describe('TodoList', () => {
it('should add todo', () => {
render(<TodoList />);
// Complete test
});
it('should delete todo', () => {
render(<TodoList />); // Fresh render
// Complete test
});
});
// โ BAD - Tests depend on order
let todos = [];
describe('TodoList', () => {
it('should add todo', () => {
todos.push({ id: 1, text: 'Todo' });
});
it('should delete todo', () => {
// Depends on previous test
expect(todos.length).toBe(1);
});
});
Coverage Goals
Statements: > 80%
Branches: > 75%
Functions: > 80%
Lines: > 80%
Avoid Anti-Patterns
// โ BAD - Testing implementation details
it('should call setState', () => {
const setState = vi.fn();
render(<Component setState={setState} />);
// Testing setState directly
});
// โ
GOOD - Testing user behavior
it('should display updated text', async () => {
const user = userEvent.setup();
render(<Component />);
await user.type(input, 'text');
expect(screen.getByText('text')).toBeInTheDocument();
});
Debugging Tests
Enable Debug Output
import { render, screen } from '@testing-library/react';
it('should render correctly', () => {
const { debug } = render(<MyComponent />);
// Print DOM tree
debug();
// Or specific element
debug(screen.getByRole('button'));
});
Log Test State
import { screen } from '@testing-library/react';
it('should show data', async () => {
render(<Component />);
console.log('Rendered queries:', screen.queryAllByRole('button'));
await waitFor(() => {
console.log('After wait:', screen.getByText('Data'));
});
});
Coverage Report
Run coverage to see what needs testing:
bun test:coverage-all
# View HTML report
open coverage/index.html
Common Testing Utilities
Custom Render
// src/test/test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { Providers } from '@/src/components/context/Providers';
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: Providers, ...options });
export * from '@testing-library/react';
export { customRender as render };
Test Helpers
// src/test/test-helpers.ts
import { waitFor } from '@testing-library/react';
export async function waitForLoadingToFinish() {
const loadingElements = document.querySelectorAll('[role="status"]');
return Promise.all(
Array.from(loadingElements).map(el =>
waitFor(() => expect(el).not.toBeInTheDocument())
)
);
}
export function fillForm(values: Record<string, string>) {
// Helper to fill out forms
}
Related Documentation
- ๐๏ธ ARCHITECTURE.md - Testing architecture
- ๐ COMPONENTS.md - Component testing examples
- ๐ฃ HOOKS.md - Hook testing patterns
Last Updated: January 2026