25 KiB

Testing Strategy and Guide

Comprehensive guide to testing Changemaker Lite V2, covering unit tests, integration tests, and end-to-end testing strategies.

Overview

Current Status: Phase 15 (Testing + Polish) in progress. Test infrastructure is being implemented.

This guide covers:

  • Testing philosophy and strategy
  • Test frameworks (Jest, Vitest, React Testing Library)
  • Writing tests for API and Frontend
  • Running tests and generating coverage
  • Testing best practices

Testing Philosophy

Test Pyramid

       /\
      /E2E\         ← Few, high-value end-to-end tests
     /------\
    /Integration\   ← Moderate integration tests
   /------------\
  /   Unit Tests  \ ← Many, fast unit tests
 /----------------\

Unit Tests (70%):

  • Test individual functions/components
  • Fast execution (milliseconds)
  • No external dependencies
  • Easy to write and maintain

Integration Tests (20%):

  • Test multiple units working together
  • Test API routes with database
  • Test user flows in frontend
  • Moderate execution time

End-to-End Tests (10%):

  • Test complete user journeys
  • Test across API and frontend
  • Slow execution (seconds)
  • Complex setup

Testing Principles

  1. Test Behavior, Not Implementation

    • Test what the code does, not how it does it
    • Allows refactoring without breaking tests
  2. Arrange-Act-Assert (AAA) Pattern

    • Arrange: Set up test data and mocks
    • Act: Execute the code under test
    • Assert: Verify expected behavior
  3. Independent Tests

    • Each test runs in isolation
    • No shared state between tests
    • Tests can run in any order
  4. Fast Feedback

    • Tests run quickly (< 1 second each)
    • Run tests in watch mode during development
    • Run full suite in CI/CD
  5. Readable Tests

    • Clear test names describing what is tested
    • Simple setup and assertions
    • Good error messages when tests fail

Test Frameworks

API Testing (Jest)

Framework: Jest Location: api/src/**/*.test.ts Config: api/jest.config.js

Installation:

cd api
npm install --save-dev jest @types/jest ts-jest
npm install --save-dev @types/supertest supertest

Configuration (jest.config.js):

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.test.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Frontend Testing (Vitest + React Testing Library)

Framework: Vitest (Vite-native test runner) Component Testing: React Testing Library Location: admin/src/**/*.test.tsx, admin/src/**/*.spec.tsx Config: admin/vitest.config.ts

Installation:

cd admin
npm install --save-dev vitest @vitest/ui
npm install --save-dev @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event

Configuration (vitest.config.ts):

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/mockData'
      ]
    }
  }
});

Setup File (admin/src/test/setup.ts):

import '@testing-library/jest-dom';
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

// Cleanup after each test
afterEach(() => {
  cleanup();
});

API Testing

Unit Tests (Service Layer)

Test business logic in service files:

Example: api/src/modules/auth/auth.service.test.ts

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AuthService } from './auth.service';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';

// Mock Prisma
vi.mock('@prisma/client');

describe('AuthService', () => {
  let authService: AuthService;
  let mockPrisma: any;

  beforeEach(() => {
    mockPrisma = {
      user: {
        findUnique: vi.fn(),
        create: vi.fn()
      }
    };
    authService = new AuthService(mockPrisma);
  });

  describe('login', () => {
    it('should return tokens for valid credentials', async () => {
      // Arrange
      const email = 'test@example.com';
      const password = 'Password123!';
      const hashedPassword = await bcrypt.hash(password, 10);

      mockPrisma.user.findUnique.mockResolvedValue({
        id: 1,
        email,
        password: hashedPassword,
        role: 'USER'
      });

      // Act
      const result = await authService.login(email, password);

      // Assert
      expect(result).toHaveProperty('accessToken');
      expect(result).toHaveProperty('refreshToken');
      expect(result.user.email).toBe(email);
    });

    it('should throw error for invalid password', async () => {
      // Arrange
      const email = 'test@example.com';
      const hashedPassword = await bcrypt.hash('correctpass', 10);

      mockPrisma.user.findUnique.mockResolvedValue({
        id: 1,
        email,
        password: hashedPassword,
        role: 'USER'
      });

      // Act & Assert
      await expect(
        authService.login(email, 'wrongpass')
      ).rejects.toThrow('Invalid credentials');
    });

    it('should throw error for non-existent user', async () => {
      // Arrange
      mockPrisma.user.findUnique.mockResolvedValue(null);

      // Act & Assert
      await expect(
        authService.login('nonexistent@example.com', 'password')
      ).rejects.toThrow('Invalid credentials');
    });
  });

  describe('register', () => {
    it('should create new user with hashed password', async () => {
      // Arrange
      const email = 'new@example.com';
      const password = 'Password123!';

      mockPrisma.user.findUnique.mockResolvedValue(null);
      mockPrisma.user.create.mockResolvedValue({
        id: 1,
        email,
        role: 'USER'
      });

      // Act
      const result = await authService.register(email, password);

      // Assert
      expect(mockPrisma.user.create).toHaveBeenCalledWith({
        data: expect.objectContaining({
          email,
          password: expect.any(String),
          role: 'USER'
        })
      });
      expect(result.user.email).toBe(email);
    });

    it('should throw error if user already exists', async () => {
      // Arrange
      mockPrisma.user.findUnique.mockResolvedValue({
        id: 1,
        email: 'existing@example.com'
      });

      // Act & Assert
      await expect(
        authService.register('existing@example.com', 'Password123!')
      ).rejects.toThrow('User already exists');
    });
  });
});

Integration Tests (Routes)

Test API endpoints with database:

Example: api/src/modules/auth/auth.routes.test.ts

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../../server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

describe('Auth Routes', () => {
  beforeAll(async () => {
    // Setup test database
    await prisma.$connect();
  });

  afterAll(async () => {
    // Cleanup
    await prisma.user.deleteMany();
    await prisma.$disconnect();
  });

  describe('POST /api/auth/register', () => {
    it('should register new user', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'test@example.com',
          password: 'Password123!'
        })
        .expect(201);

      expect(response.body).toHaveProperty('accessToken');
      expect(response.body).toHaveProperty('refreshToken');
      expect(response.body.user.email).toBe('test@example.com');
    });

    it('should return 400 for invalid email', async () => {
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'invalid-email',
          password: 'Password123!'
        })
        .expect(400);

      expect(response.body).toHaveProperty('error');
    });

    it('should return 409 for existing user', async () => {
      // Create user first
      await request(app)
        .post('/api/auth/register')
        .send({
          email: 'existing@example.com',
          password: 'Password123!'
        });

      // Try to create again
      const response = await request(app)
        .post('/api/auth/register')
        .send({
          email: 'existing@example.com',
          password: 'Password123!'
        })
        .expect(409);

      expect(response.body.error).toContain('already exists');
    });
  });

  describe('POST /api/auth/login', () => {
    it('should login with valid credentials', async () => {
      // Register user first
      await request(app)
        .post('/api/auth/register')
        .send({
          email: 'login@example.com',
          password: 'Password123!'
        });

      // Login
      const response = await request(app)
        .post('/api/auth/login')
        .send({
          email: 'login@example.com',
          password: 'Password123!'
        })
        .expect(200);

      expect(response.body).toHaveProperty('accessToken');
      expect(response.body).toHaveProperty('refreshToken');
    });

    it('should return 401 for invalid password', async () => {
      const response = await request(app)
        .post('/api/auth/login')
        .send({
          email: 'login@example.com',
          password: 'WrongPassword!'
        })
        .expect(401);

      expect(response.body.error).toContain('Invalid credentials');
    });
  });
});

Database Testing

Use separate test database:

Environment Variable (.env.test):

DATABASE_URL=postgresql://changemaker_v2:password@localhost:5433/changemaker_v2_test_db

Setup Script (api/src/test/setup.ts):

import { PrismaClient } from '@prisma/client';
import { execSync } from 'child_process';

const prisma = new PrismaClient();

export async function setupTestDatabase() {
  // Apply migrations
  execSync('npx prisma migrate deploy', {
    env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL }
  });

  // Clean data
  await prisma.user.deleteMany();
  await prisma.campaign.deleteMany();
  // ... delete all tables
}

export async function teardownTestDatabase() {
  await prisma.$disconnect();
}

Frontend Testing

Component Unit Tests

Test individual React components:

Example: admin/src/components/UserCard.test.tsx

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { UserCard } from './UserCard';

describe('UserCard', () => {
  it('renders user information', () => {
    const user = {
      id: 1,
      email: 'test@example.com',
      role: 'USER',
      name: 'Test User'
    };

    render(<UserCard user={user} />);

    expect(screen.getByText('Test User')).toBeInTheDocument();
    expect(screen.getByText('test@example.com')).toBeInTheDocument();
    expect(screen.getByText('USER')).toBeInTheDocument();
  });

  it('renders "No name" when name is null', () => {
    const user = {
      id: 1,
      email: 'test@example.com',
      role: 'USER',
      name: null
    };

    render(<UserCard user={user} />);

    expect(screen.getByText('No name')).toBeInTheDocument();
  });
});

Component Integration Tests

Test user interactions:

Example: admin/src/pages/LoginPage.test.tsx

import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginPage } from './LoginPage';
import { BrowserRouter } from 'react-router-dom';
import * as api from '../lib/api';

// Mock API
vi.mock('../lib/api');

describe('LoginPage', () => {
  it('submits login form with valid credentials', async () => {
    const user = userEvent.setup();
    const mockLogin = vi.spyOn(api, 'login').mockResolvedValue({
      accessToken: 'token',
      refreshToken: 'refresh',
      user: { id: 1, email: 'test@example.com', role: 'USER' }
    });

    render(
      <BrowserRouter>
        <LoginPage />
      </BrowserRouter>
    );

    // Fill form
    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'Password123!');

    // Submit
    await user.click(screen.getByRole('button', { name: /login/i }));

    // Verify API called
    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'Password123!'
      });
    });
  });

  it('shows error for invalid credentials', async () => {
    const user = userEvent.setup();
    vi.spyOn(api, 'login').mockRejectedValue(
      new Error('Invalid credentials')
    );

    render(
      <BrowserRouter>
        <LoginPage />
      </BrowserRouter>
    );

    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'wrong');
    await user.click(screen.getByRole('button', { name: /login/i }));

    await waitFor(() => {
      expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
    });
  });

  it('disables submit button while loading', async () => {
    const user = userEvent.setup();
    vi.spyOn(api, 'login').mockImplementation(
      () => new Promise(resolve => setTimeout(resolve, 1000))
    );

    render(
      <BrowserRouter>
        <LoginPage />
      </BrowserRouter>
    );

    const submitButton = screen.getByRole('button', { name: /login/i });

    await user.type(screen.getByLabelText(/email/i), 'test@example.com');
    await user.type(screen.getByLabelText(/password/i), 'Password123!');
    await user.click(submitButton);

    expect(submitButton).toBeDisabled();
  });
});

Testing Hooks

Test custom React hooks:

Example: admin/src/hooks/useDebounce.test.ts

import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('debounces value changes', async () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    );

    expect(result.current).toBe('initial');

    // Change value
    rerender({ value: 'updated', delay: 500 });

    // Value should not change immediately
    expect(result.current).toBe('initial');

    // Wait for debounce
    await waitFor(() => {
      expect(result.current).toBe('updated');
    }, { timeout: 600 });
  });
});

Testing Zustand Stores

Test state management:

Example: admin/src/stores/auth.store.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAuthStore } from './auth.store';

describe('Auth Store', () => {
  beforeEach(() => {
    // Reset store before each test
    const { result } = renderHook(() => useAuthStore());
    act(() => {
      result.current.logout();
    });
  });

  it('sets user on login', () => {
    const { result } = renderHook(() => useAuthStore());

    act(() => {
      result.current.setUser({
        id: 1,
        email: 'test@example.com',
        role: 'USER'
      });
    });

    expect(result.current.user).toEqual({
      id: 1,
      email: 'test@example.com',
      role: 'USER'
    });
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('clears user on logout', () => {
    const { result } = renderHook(() => useAuthStore());

    act(() => {
      result.current.setUser({
        id: 1,
        email: 'test@example.com',
        role: 'USER'
      });
    });

    expect(result.current.isAuthenticated).toBe(true);

    act(() => {
      result.current.logout();
    });

    expect(result.current.user).toBeNull();
    expect(result.current.isAuthenticated).toBe(false);
  });
});

Running Tests

Run All Tests

# API tests
cd api
npm test

# Frontend tests
cd admin
npm test

Watch Mode

Run tests automatically on file changes:

# API tests (Jest watch)
cd api
npm run test:watch

# Frontend tests (Vitest watch)
cd admin
npm run test:watch

Run Specific Tests

# Run specific test file
npm test -- auth.service.test.ts

# Run tests matching pattern
npm test -- --testNamePattern="login"

# Run tests in specific directory
npm test -- src/modules/auth/

Coverage Reports

Generate test coverage:

# API coverage
cd api
npm run test:coverage

# Frontend coverage
cd admin
npm run test:coverage

Coverage output:

File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   82.45 |    75.33 |   80.12 |   83.21 |
 auth/              |   95.23 |    89.47 |   93.75 |   96.15 |
  auth.service.ts   |   97.14 |    91.67 |   100   |   98.21 |
  auth.routes.ts    |   93.33 |    87.50 |   87.50 |   94.12 |

HTML report:

  • Located in coverage/ directory
  • Open coverage/index.html in browser
  • Shows line-by-line coverage

CI/CD Testing

GitHub Actions Example:

name: Tests

on: [push, pull_request]

jobs:
  api-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'

      - name: Install dependencies
        working-directory: ./api
        run: npm ci

      - name: Run migrations
        working-directory: ./api
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test
        run: npx prisma migrate deploy

      - name: Run tests
        working-directory: ./api
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./api/coverage/coverage-final.json

  frontend-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'

      - name: Install dependencies
        working-directory: ./admin
        run: npm ci

      - name: Run tests
        working-directory: ./admin
        run: npm test -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./admin/coverage/coverage-final.json

Mocking

Mocking API Calls (Frontend)

// Mock axios
vi.mock('../lib/api', () => ({
  api: {
    get: vi.fn(),
    post: vi.fn(),
    put: vi.fn(),
    delete: vi.fn()
  }
}));

// Use in test
import { api } from '../lib/api';

vi.mocked(api.get).mockResolvedValue({
  data: { users: [] }
});

Mocking Database (Backend)

// Mock Prisma Client
vi.mock('@prisma/client', () => ({
  PrismaClient: vi.fn(() => ({
    user: {
      findUnique: vi.fn(),
      findMany: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn()
    }
  }))
}));

Mocking External Services

// Mock email service
vi.mock('../../services/email.service', () => ({
  EmailService: {
    sendEmail: vi.fn().mockResolvedValue(true)
  }
}));

Mocking Environment Variables

// Set env var for test
process.env.JWT_ACCESS_SECRET = 'test-secret';

// Or use vi.stubEnv
vi.stubEnv('API_URL', 'http://localhost:4000');

Best Practices

Test Naming

Use descriptive test names:

Good:

it('should return 401 for expired token', async () => {});
it('should create user with hashed password', async () => {});
it('should render error message for invalid email', () => {});

Bad:

it('works', async () => {});
it('test login', async () => {});
it('should work correctly', () => {});

Test Organization

Group related tests:

describe('AuthService', () => {
  describe('login', () => {
    it('should return tokens for valid credentials', () => {});
    it('should throw error for invalid password', () => {});
    it('should throw error for non-existent user', () => {});
  });

  describe('register', () => {
    it('should create new user', () => {});
    it('should hash password', () => {});
    it('should throw error if user exists', () => {});
  });
});

Setup and Teardown

Use beforeEach/afterEach for common setup:

describe('UserService', () => {
  let userService: UserService;
  let mockPrisma: any;

  beforeEach(() => {
    mockPrisma = createMockPrisma();
    userService = new UserService(mockPrisma);
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  it('...', () => {});
});

Avoid Test Interdependence

Each test should be independent:

Good:

it('should create user', async () => {
  const user = await createUser({ email: 'test@example.com' });
  expect(user.email).toBe('test@example.com');
});

it('should update user', async () => {
  const user = await createUser({ email: 'test@example.com' });
  const updated = await updateUser(user.id, { name: 'New Name' });
  expect(updated.name).toBe('New Name');
});

Bad:

let userId;

it('should create user', async () => {
  const user = await createUser({ email: 'test@example.com' });
  userId = user.id; // ❌ Shared state
});

it('should update user', async () => {
  const updated = await updateUser(userId, { name: 'New Name' });
  // ❌ Depends on previous test
});

Test Edge Cases

Test boundary conditions:

describe('pagination', () => {
  it('should handle page 1', () => {});
  it('should handle last page', () => {});
  it('should handle empty results', () => {});
  it('should handle invalid page number', () => {});
  it('should handle page exceeding total', () => {});
});

Async Testing

Always use async/await for async tests:

Good:

it('should fetch users', async () => {
  const users = await userService.getUsers();
  expect(users).toHaveLength(10);
});

Bad:

it('should fetch users', () => {
  userService.getUsers().then(users => {
    expect(users).toHaveLength(10); // ❌ May not run
  });
});

Coverage Requirements

Target coverage thresholds:

// jest.config.js / vitest.config.ts
coverageThreshold: {
  global: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 80
  }
}

What to test:

  • Business logic (services)
  • API routes
  • UI components
  • Custom hooks
  • Utilities
  • Type definitions
  • Config files
  • Test files themselves

Troubleshooting

Tests Timing Out

Problem: Tests exceed timeout.

Solution:

// Increase timeout for specific test
it('slow operation', async () => {
  // ...
}, 10000); // 10 second timeout

// Or globally (vitest.config.ts)
export default defineConfig({
  test: {
    testTimeout: 10000
  }
});

Mocks Not Working

Problem: Mocks not being used.

Solution:

// Mock must be at top of file, before imports
vi.mock('../lib/api');

import { api } from '../lib/api';

// Verify mock is being used
console.log(vi.isMockFunction(api.get)); // Should be true

Database Connection Errors

Problem: Tests fail with DB connection errors.

Solution:

// Use separate test database
process.env.DATABASE_URL = 'postgresql://localhost/test_db';

// Or mock database entirely
vi.mock('@prisma/client');

React Testing Library Queries Failing

Problem: screen.getByText() doesn't find element.

Solution:

// Use findBy for async elements
const element = await screen.findByText('Loading...');

// Use queryBy to check non-existence
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// Debug rendered output
screen.debug();

Summary

You now know:

  • Testing philosophy (test pyramid, AAA pattern)
  • Test frameworks (Jest, Vitest, React Testing Library)
  • How to write unit tests (services, components)
  • How to write integration tests (routes, user flows)
  • How to run tests and generate coverage
  • How to mock dependencies
  • Testing best practices
  • How to integrate tests in CI/CD

Quick Start:

# Install dependencies (when Phase 15 complete)
cd api && npm install --save-dev jest @types/jest ts-jest
cd admin && npm install --save-dev vitest @vitest/ui

# Run tests
npm test

# Watch mode
npm run test:watch

# Coverage
npm run test:coverage