Skip to content

Testing Guide

This guide covers the testing strategy, tools, and best practices for the Coaching App project.

Table of Contents

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:

coverageThreshold: {
  global: {
    statements: 80,
    branches: 80,
    functions: 80,
    lines: 80,
  },
}

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_URL
  • TEST_ADMIN_EMAIL
  • TEST_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:

# Runs in CI
yarn test:coverage --ci --maxWorkers=2

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 limit
  • Jest worker ran out of memory and crashed
  • Tests pass individually but fail when run together

Solutions Implemented:

  1. Increased Node.js Heap Size (in package.json):
{
  "scripts": {
    "test": "NODE_OPTIONS='--max-old-space-size=8192' jest"
  }
}
  1. Reduced Jest Workers (in frontend/jest.config.js):
maxWorkers: 1; // Down from 2
  1. Split Large Test Files: Tests with 30+ cases were split into smaller focused files:
  2. 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)
  3. Reduced Console Output: Conditional logging to avoid test output bloat:

if (process.env.NODE_ENV !== 'test') {
  console.log('Debug info');
}
  1. 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

Resources