Skip to content

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
  2. Test what the code does, not how it does it
  3. Allows refactoring without breaking tests

  4. Arrange-Act-Assert (AAA) Pattern

  5. Arrange: Set up test data and mocks
  6. Act: Execute the code under test
  7. Assert: Verify expected behavior

  8. Independent Tests

  9. Each test runs in isolation
  10. No shared state between tests
  11. Tests can run in any order

  12. Fast Feedback

  13. Tests run quickly (< 1 second each)
  14. Run tests in watch mode during development
  15. Run full suite in CI/CD

  16. Readable Tests

  17. Clear test names describing what is tested
  18. Simple setup and assertions
  19. 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