Comprehensive guide for writing and running tests in Open Ham Prep.
This project uses:
- Vitest - Fast unit test framework
- React Testing Library - Component testing utilities
- @testing-library/user-event - User interaction simulation
- @testing-library/jest-dom - Custom DOM matchers
# Run all tests in watch mode
npm test
# Run tests once (CI mode)
npm run test:run
# Run tests with UI
npm run test:ui
# Run tests with coverage report
npm run test:coverage
# Run tests with local Supabase
npm run test:local
# Watch mode for specific file
npm test -- useQuestions.test.tsAfter running npm run test:coverage, open coverage/index.html in your browser to see detailed coverage reports.
Tests are colocated with the files they test:
src/
├── hooks/
│ ├── useQuestions.ts
│ └── useQuestions.test.ts # ✓ Hook tests
├── components/
│ ├── QuestionCard.tsx
│ └── QuestionCard.test.tsx # ✓ Component tests
└── lib/
├── utils.ts
└── utils.test.ts # ✓ Utility tests
- Test files:
*.test.tsor*.test.tsx - Test suites:
describe('ComponentName', () => {...}) - Test cases:
it('should do something', () => {...})
Testing custom React hooks with TanStack Query:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useYourHook } from './useYourHook';
// Create test wrapper
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
describe('useYourHook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('fetches data successfully', async () => {
// Mock Supabase call
const mockData = [{ id: '1', name: 'Test' }];
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn().mockResolvedValue({
data: mockData,
error: null,
}),
});
const { result } = renderHook(() => useYourHook(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockData);
});
});Testing React components with user interactions:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { YourComponent } from './YourComponent';
describe('YourComponent', () => {
it('renders correctly', () => {
render(<YourComponent />);
expect(screen.getByText('Expected Text')).toBeInTheDocument();
});
it('handles user interaction', async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<YourComponent onClick={onClickMock} />);
await user.click(screen.getByRole('button'));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it('shows loading state', async () => {
render(<YourComponent isLoading={true} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});Testing pure functions:
import { describe, it, expect } from 'vitest';
import { yourFunction } from './utils';
describe('yourFunction', () => {
it('transforms input correctly', () => {
const input = { foo: 'bar' };
const result = yourFunction(input);
expect(result).toEqual({ foo: 'BAR' });
});
it('handles edge cases', () => {
expect(yourFunction(null)).toBeNull();
expect(yourFunction(undefined)).toBeUndefined();
});
});vi.mock('@/integrations/supabase/client', () => ({
supabase: {
from: vi.fn(() => ({
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
})),
auth: {
getSession: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
},
},
}));vi.mock('./useAuth', () => ({
useAuth: vi.fn(() => ({
user: { id: 'test-user-id' },
loading: false,
})),
}));vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));Run npm run test:coverage to see current coverage:
----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------------------|---------|----------|---------|---------|
All files | 78.45 | 71.23 | 82.11 | 78.90 |
hooks/ | 85.32 | 79.45 | 88.23 | 86.12 |
useQuestions.ts | 92.15 | 87.50 | 95.00 | 93.20 |
useProgress.ts | 88.45 | 82.30 | 91.67 | 89.10 |
useBookmarks.ts | 90.20 | 85.10 | 93.75 | 91.50 |
components/ | 73.21 | 65.89 | 77.45 | 74.32 |
lib/ | 95.67 | 91.23 | 97.50 | 96.12 |
----------------------|---------|----------|---------|---------|
- Statements: > 80%
- Branches: > 75%
- Functions: > 80%
- Lines: > 80%
High Priority:
- ✅ Core business logic (hooks)
- ✅ Data transformations
- ✅ User interactions
- ✅ Error handling
- ✅ Edge cases
Medium Priority:
⚠️ Component rendering⚠️ Conditional logic⚠️ Form validation
Low Priority:
- ⬜ UI-only components
- ⬜ Simple presentational components
- ⬜ Third-party integrations
As configured in vitest.config.ts:
node_modules/src/test/- Test utilities**/*.d.ts- Type definitionssrc/integrations/supabase/types.ts- Auto-generated types
Tests run automatically on:
- Push to
mainordevelopbranches - Pull requests targeting
mainordevelop
The workflow:
- Runs tests on Node 18 and 20
- Checks linting (
npm run lint) - Checks types (
npx tsc --noEmit) - Runs test suite (
npm run test:run) - Generates coverage report
- Uploads to Codecov
- Checks build (
npm run build)
See .github/workflows/test.yml for details.
Consider adding pre-commit hooks with Husky:
npm install --save-dev husky lint-staged
# Add to package.json:
{
"lint-staged": {
"*.{ts,tsx}": [
"npm run lint:fix",
"npm run test -- --related"
]
}
}-
Test behavior, not implementation
// ✅ Good - tests what user sees expect(screen.getByText('Question 1 of 35')).toBeInTheDocument(); // ❌ Bad - tests internal state expect(component.state.currentIndex).toBe(0);
-
Use Testing Library queries correctly
// ✅ Preferred order: screen.getByRole('button', { name: /submit/i }); screen.getByLabelText('Email'); screen.getByPlaceholderText('Enter email'); screen.getByText(/loading/i); // ❌ Avoid: screen.getByTestId('submit-button'); // Only as last resort
-
Test async operations properly
// ✅ Good - waits for async updates await waitFor(() => { expect(screen.getByText('Success!')).toBeInTheDocument(); }); // ❌ Bad - might pass before update expect(screen.queryByText('Success!')).toBeInTheDocument();
-
Mock external dependencies
// ✅ Good - isolated test vi.mock('@/integrations/supabase/client'); // ❌ Bad - depends on real API // (no mocking)
-
Clean up after each test
beforeEach(() => { vi.clearAllMocks(); });
- Don't test implementation details
- Don't rely on test execution order
- Don't use arbitrary timeouts
- Don't mock what you're testing
- Don't ignore TypeScript errors in tests
describe('ComponentName', () => {
// Setup
beforeEach(() => {
vi.clearAllMocks();
});
// Group related tests
describe('rendering', () => {
it('renders with default props', () => {});
it('renders loading state', () => {});
});
describe('user interactions', () => {
it('handles click', () => {});
it('handles form submission', () => {});
});
describe('edge cases', () => {
it('handles empty data', () => {});
it('handles errors', () => {});
});
});it('submits form with valid data', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<Form onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});it('displays error message on failure', async () => {
vi.mocked(supabase.from).mockReturnValue({
select: vi.fn().mockResolvedValue({
data: null,
error: new Error('Network error'),
}),
});
render(<Component />);
await waitFor(() => {
expect(screen.getByText(/error loading data/i)).toBeInTheDocument();
});
});it('shows loading spinner while fetching', () => {
render(<Component isLoading={true} />);
expect(screen.getByRole('status')).toBeInTheDocument();
});// Increase timeout for slow operations
it('handles slow operation', async () => {
// ...
}, 10000); // 10 second timeout// Wrap state updates in act()
await act(async () => {
await result.current.mutateAsync();
});// Ensure mock is defined before import
vi.mock('./module');
// Then import
import { useHook } from './module';See existing test files for examples:
src/hooks/useQuestions.test.ts- Hook testingsrc/hooks/useProgress.test.ts- Async operationssrc/hooks/useBookmarks.test.ts- Mutationssrc/components/QuestionCard.test.tsx- Component testingsrc/lib/utils.test.ts- Utility functions
When adding new code:
- Write tests for new features
- Maintain or improve coverage
- Follow existing test patterns
- Run tests before committing:
npm run test:run npm run lint npm run build
For questions or issues with tests, open a GitHub issue or check existing test files for patterns.