Testing Guide¶
This guide covers the testing strategy, tools, and best practices for the Coaching App project.
Table of Contents¶
- Testing Philosophy
- Testing Tools
- Code Coverage Requirements
- Unit Testing
- Integration Testing
- Testing React Components
- Testing React Native
- Testing Hono Route Handlers
- Running Tests
- Best Practices
Testing Philosophy¶
We follow the testing pyramid:
/\
/E2E\ ← Few (planned for future)
/------\
/ Integ \ ← Some
/----------\
/ Unit Tests \ ← Many
/--------------\
- Unit Tests (80%): Test individual functions, components, and utilities
- Integration Tests (15%): Test interactions between modules
- E2E Tests (5%): Test complete user workflows (see ROADMAP.md)
Testing Tools¶
| Tool | Purpose | Used In |
|---|---|---|
| Jest | Test runner and assertion library | All packages |
| React Testing Library | Test React components | Frontend |
| React Native Testing Library | Test React Native components | Mobile |
| Supertest | Test HTTP endpoints | Backend |
supertest / app.request() |
Test Hono route handlers | Backend |
Code Coverage Requirements¶
Thresholds¶
All packages must maintain minimum 80% coverage:
Exclusions¶
Don't count toward coverage:
- Type definition files (
*.d.ts) - Test files (
*.test.ts,*.spec.ts) - Index files (
index.ts) that only re-export - Generated code
- Configuration files
Viewing Coverage¶
# Generate coverage report
yarn test:coverage
# Open HTML report
open coverage/lcov-report/index.html
Unit Testing¶
Testing Utilities¶
Example: shared/src/utils/pricing.ts
export function calculateSessionPrice(params: {
duration: number;
type: 'standard' | 'premium';
memberTier?: 'basic' | 'premium';
}): number {
const basePrice = params.type === 'premium' ? 150 : 100;
const pricePerMinute = basePrice / 60;
const totalPrice = pricePerMinute * params.duration;
// Apply discount for premium members
if (params.memberTier === 'premium') {
return totalPrice * 0.8; // 20% discount
}
return totalPrice;
}
Test: shared/src/utils/__tests__/pricing.test.ts
import { calculateSessionPrice } from '../pricing';
describe('calculateSessionPrice', () => {
describe('standard sessions', () => {
it('calculates correct price for 60-minute session', () => {
const price = calculateSessionPrice({
duration: 60,
type: 'standard',
});
expect(price).toBe(100);
});
it('calculates correct price for 30-minute session', () => {
const price = calculateSessionPrice({
duration: 30,
type: 'standard',
});
expect(price).toBe(50);
});
});
describe('premium sessions', () => {
it('calculates correct price for premium session', () => {
const price = calculateSessionPrice({
duration: 60,
type: 'premium',
});
expect(price).toBe(150);
});
});
describe('member discounts', () => {
it('applies 20% discount for premium members', () => {
const price = calculateSessionPrice({
duration: 60,
type: 'standard',
memberTier: 'premium',
});
expect(price).toBe(80); // 100 * 0.8
});
it('does not apply discount for basic members', () => {
const price = calculateSessionPrice({
duration: 60,
type: 'standard',
memberTier: 'basic',
});
expect(price).toBe(100);
});
});
});
Testing Async Functions¶
// Function to test
export async function fetchUserProfile(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
// Test
describe('fetchUserProfile', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
it('fetches user successfully', async () => {
const mockUser = { id: '123', name: 'John Doe' };
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const user = await fetchUserProfile('123');
expect(user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith('/api/users/123');
});
it('throws error on failed request', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
});
await expect(fetchUserProfile('123')).rejects.toThrow('Failed to fetch user');
});
});
Integration Testing¶
Hono API Integration Tests¶
Integration tests run against the Hono backend with a dedicated test database to keep data isolated.
When you add or change backend features or endpoints, add matching integration tests in backend/__tests__/.
# Start the test stack
docker compose -f docker-compose.test.yml up -d
# Run backend tests
pnpm workspace @coaching-app/backend test
Defaults (set in backend/__tests__/global-setup.ts):
- API URL:
http://localhost:3001 - Admin credentials:
platformadmin@coaching.test/PlatformAdmin1234!
You can override with environment variables:
TEST_API_URLTEST_ADMIN_EMAILTEST_ADMIN_PASSWORD
Testing Module Interactions¶
Example: Testing service layer with repository
// services/SessionService.ts
import { SessionRepository } from '../repositories/SessionRepository';
import { calculateSessionPrice } from '@coaching-app/shared';
export class SessionService {
constructor(private sessionRepo: SessionRepository) {}
async createSession(data: CreateSessionInput): Promise<Session> {
const price = calculateSessionPrice({
duration: data.duration,
type: data.type,
});
return this.sessionRepo.create({
...data,
price,
});
}
}
// __tests__/SessionService.test.ts
import { SessionService } from '../SessionService';
import { SessionRepository } from '../repositories/SessionRepository';
jest.mock('../repositories/SessionRepository');
describe('SessionService', () => {
let service: SessionService;
let mockRepo: jest.Mocked<SessionRepository>;
beforeEach(() => {
mockRepo = new SessionRepository() as jest.Mocked<SessionRepository>;
service = new SessionService(mockRepo);
});
it('creates session with calculated price', async () => {
const input = {
title: 'Career Coaching',
duration: 60,
type: 'standard' as const,
};
mockRepo.create.mockResolvedValueOnce({
id: '123',
...input,
price: 100,
});
const session = await service.createSession(input);
expect(session.price).toBe(100);
expect(mockRepo.create).toHaveBeenCalledWith({
...input,
price: 100,
});
});
});
Testing React Components¶
Using React Testing Library¶
Component: frontend/src/components/SessionCard.tsx
interface SessionCardProps {
session: Session;
onBook: (sessionId: string) => void;
}
export function SessionCard({ session, onBook }: SessionCardProps) {
return (
<div data-testid="session-card">
<h3>{session.title}</h3>
<p>{session.duration} minutes</p>
<p>${session.price}</p>
<button onClick={() => onBook(session.id)}>
Book Session
</button>
</div>
);
}
Test: frontend/src/components/__tests__/SessionCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { SessionCard } from '../SessionCard';
describe('SessionCard', () => {
const mockSession = {
id: '123',
title: 'Career Coaching',
duration: 60,
price: 100,
};
it('renders session details', () => {
render(<SessionCard session={mockSession} onBook={jest.fn()} />);
expect(screen.getByText('Career Coaching')).toBeInTheDocument();
expect(screen.getByText('60 minutes')).toBeInTheDocument();
expect(screen.getByText('$100')).toBeInTheDocument();
});
it('calls onBook when button clicked', () => {
const mockOnBook = jest.fn();
render(<SessionCard session={mockSession} onBook={mockOnBook} />);
fireEvent.click(screen.getByText('Book Session'));
expect(mockOnBook).toHaveBeenCalledWith('123');
});
it('is accessible', () => {
const { container } = render(
<SessionCard session={mockSession} onBook={jest.fn()} />
);
const card = screen.getByTestId('session-card');
expect(card).toBeInTheDocument();
});
});
Testing Hooks¶
// hooks/useSessionBooking.ts
export function useSessionBooking() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const bookSession = async (sessionId: string) => {
setLoading(true);
setError(null);
try {
await api.bookSession(sessionId);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return { bookSession, loading, error };
}
// __tests__/useSessionBooking.test.ts
import { renderHook, act } from '@testing-library/react';
import { useSessionBooking } from '../useSessionBooking';
jest.mock('../api');
describe('useSessionBooking', () => {
it('handles successful booking', async () => {
const { result } = renderHook(() => useSessionBooking());
await act(async () => {
await result.current.bookSession('123');
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it('handles booking error', async () => {
const mockError = new Error('Booking failed');
api.bookSession.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useSessionBooking());
await act(async () => {
await result.current.bookSession('123');
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe('Booking failed');
});
});
Testing React Native¶
Using React Native Testing Library¶
Component: mobile/src/screens/SessionList.tsx
export function SessionListScreen() {
const { sessions, loading } = useSessions();
if (loading) {
return <ActivityIndicator testID="loading-indicator" />;
}
return (
<FlatList
testID="session-list"
data={sessions}
renderItem={({ item }) => <SessionCard session={item} />}
keyExtractor={(item) => item.id}
/>
);
}
Test: mobile/src/screens/__tests__/SessionList.test.tsx
import { render, screen } from '@testing-library/react-native';
import { SessionListScreen } from '../SessionList';
jest.mock('../../hooks/useSessions');
describe('SessionListScreen', () => {
it('shows loading indicator while loading', () => {
useSessions.mockReturnValue({ sessions: [], loading: true });
render(<SessionListScreen />);
expect(screen.getByTestId('loading-indicator')).toBeTruthy();
});
it('renders session list when loaded', () => {
const mockSessions = [
{ id: '1', title: 'Session 1' },
{ id: '2', title: 'Session 2' },
];
useSessions.mockReturnValue({ sessions: mockSessions, loading: false });
render(<SessionListScreen />);
expect(screen.getByTestId('session-list')).toBeTruthy();
});
});
Testing Hono Route Handlers¶
Testing Custom Endpoints¶
Route: backend/src/routes/sessions/sessions.router.ts
import { Hono } from 'hono';
import { requireAuth } from '../../middleware/auth';
import { db } from '../../db';
const app = new Hono();
app.get('/available', requireAuth, async (c) => {
const sessions = await db
.select()
.from(sessionsTable)
.where(eq(sessionsTable.status, 'available'));
return c.json(sessions);
});
export default app;
Test: backend/__tests__/sessions.test.ts
import request from 'supertest';
import { createTestApp } from '../../../test-utils';
describe('Sessions Endpoint', () => {
let app: any;
beforeAll(async () => {
app = await createTestApp();
});
it('returns available sessions', async () => {
const response = await request(app).get('/sessions/available').expect(200);
expect(response.body.data).toBeInstanceOf(Array);
});
it('requires authentication', async () => {
await request(app).get('/sessions/available').expect(401);
});
});
Testing Hooks¶
// backend/extensions/hooks/validate-session/index.ts
export default defineHook({
id: 'validate-session',
handler: ({ filter }, { database }) => {
filter('sessions.items.create', async (input) => {
if (input.duration < 30) {
throw new Error('Session must be at least 30 minutes');
}
return input;
});
},
});
// __tests__/validate-session.test.ts
describe('Validate Session Hook', () => {
it('accepts valid session duration', async () => {
const input = { duration: 60, title: 'Test' };
const result = await validateSession(input);
expect(result).toEqual(input);
});
it('rejects session under 30 minutes', async () => {
const input = { duration: 20, title: 'Test' };
await expect(validateSession(input)).rejects.toThrow('Session must be at least 30 minutes');
});
});
Running Tests¶
All Tests¶
# Run all tests
yarn test
# Watch mode (re-run on file changes)
yarn test:watch
# Generate coverage report
yarn test:coverage
Package-Specific Tests¶
# Backend only
yarn workspace @coaching-app/backend test
# Frontend only
yarn workspace @coaching-app/frontend test
# Mobile only
yarn workspace @coaching-app/mobile test
# Shared only
yarn workspace @coaching-app/shared test
Run Specific Test File¶
# Run single test file
yarn test src/utils/__tests__/pricing.test.ts
# Run tests matching pattern
yarn test --testNamePattern="Session"
# Run tests in specific folder
yarn test src/components
CI/CD¶
Tests run automatically in CI pipeline:
Best Practices¶
1. Follow AAA Pattern¶
it('does something', () => {
// Arrange: Set up test data
const input = { value: 10 };
// Act: Execute function
const result = myFunction(input);
// Assert: Verify result
expect(result).toBe(20);
});
2. Test Behavior, Not Implementation¶
// ❌ Bad: Testing implementation details
expect(component.state.isOpen).toBe(true);
// ✅ Good: Testing behavior
expect(screen.getByRole('dialog')).toBeVisible();
3. Use Descriptive Test Names¶
// ❌ Bad
it('works', () => {
/* ... */
});
// ✅ Good
it('calculates 20% discount for premium members', () => {
/* ... */
});
4. Keep Tests Independent¶
// ❌ Bad: Tests depend on each other
let userId: string;
it('creates user', () => {
userId = createUser();
});
it('updates user', () => {
updateUser(userId); // Depends on previous test
});
// ✅ Good: Each test is independent
it('creates user', () => {
const userId = createUser();
expect(userId).toBeDefined();
});
it('updates user', () => {
const userId = createUser();
updateUser(userId);
// ...
});
5. Mock External Dependencies¶
// Mock API calls
jest.mock('../api');
// Mock timers
jest.useFakeTimers();
// Mock dates
jest.spyOn(Date, 'now').mockReturnValue(1234567890);
6. Test Edge Cases¶
describe('calculateDiscount', () => {
it('handles zero amount', () => {
/* ... */
});
it('handles negative amount', () => {
/* ... */
});
it('handles very large amount', () => {
/* ... */
});
it('handles null input', () => {
/* ... */
});
});
7. Use Test Data Builders¶
// test-utils/builders.ts
export function buildSession(overrides = {}) {
return {
id: '123',
title: 'Test Session',
duration: 60,
price: 100,
...overrides,
};
}
// In tests
const session = buildSession({ duration: 90 });
Troubleshooting¶
Tests Failing in CI but Passing Locally¶
- Clear cache:
yarn test --clearCache - Check for timezone differences
- Ensure deterministic test data
- Avoid timing-dependent tests
Flaky Tests¶
// ❌ Avoid timeouts
await new Promise((resolve) => setTimeout(resolve, 1000));
// ✅ Use waitFor
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
Mock Not Working¶
// Ensure mock is before import
jest.mock('../api');
import { fetchData } from '../api';
// Clear mocks between tests
afterEach(() => {
jest.clearAllMocks();
});
Memory Issues / Heap Out of Memory¶
React Native tests with heavy mocks can consume significant memory (~4GB+), causing Jest workers to crash with "JavaScript heap out of memory" errors.
Symptoms:
FATAL ERROR: Ineffective mark-compacts near heap limitJest worker ran out of memory and crashed- Tests pass individually but fail when run together
Solutions Implemented:
- Increased Node.js Heap Size (in
package.json):
- Reduced Jest Workers (in
frontend/jest.config.js):
- Split Large Test Files: Tests with 30+ cases were split into smaller focused files:
-
ProfileScreen.test.tsx→ split into:ProfileScreen.rendering.test.tsx(view & edit mode)ProfileScreen.validation.test.tsx(form validation & save)ProfileScreen.uploads.test.tsx(avatar uploads & modals)
-
Reduced Console Output: Conditional logging to avoid test output bloat:
- Optimized Mocks: Use shared mock instances instead of per-test
require():
// At top of file
import { useQuery } from '@tanstack/react-query';
const useQueryMock = useQuery as jest.Mock;
// In beforeEach
useQueryMock.mockImplementation(() => ({
/* ... */
}));
Running Heavy Tests Separately:
Some test suites (like ProfileScreen) are temporarily skipped in the main test run. To run them:
# Run with extra memory
NODE_OPTIONS='--max-old-space-size=8192' yarn test ProfileScreen --maxWorkers=1
# Or run all tests including skipped ones
NODE_OPTIONS='--max-old-space-size=8192' yarn test --maxWorkers=1
When to Split Tests:
- File has 25+ test cases
- Test file is 500+ lines
- Uses many heavy mocks (React Navigation, TanStack Query, etc.)
- Takes >30 seconds to run