Testing

Testing

Comprehensive testing strategy for the Leaderboard system.

Testing Philosophy

  • Unit tests: Test individual functions and components
  • Integration tests: Test interactions between modules
  • No UI tests: Focus on logic and data handling
  • Fast execution: Use in-memory databases

Test Framework

The project uses Vitest for all testing:

  • Fast execution
  • TypeScript support
  • Compatible with Jest syntax
  • Built-in coverage

Running Tests

All Tests

# Run all tests
pnpm test

# Run tests in specific package
pnpm --filter @ohcnetwork/leaderboard-api test

# Watch mode
pnpm test:watch

# Coverage report
pnpm test:coverage

Package-Specific Tests

# Test database package
cd packages/db
pnpm test

# Test plugin-runner
cd packages/plugin-runner
pnpm test

# Test Next.js app
cd apps/leaderboard-web
pnpm test

Test Organization

packages/db/
└── src/
    ├── queries.ts
    └── __tests__/
        └── queries.test.ts

packages/plugin-runner/
└── src/
    ├── importers/
    │   ├── contributors.ts
    │   └── __tests__/
    │       └── contributors.test.ts
    └── exporters/
        ├── activities.ts
        └── __tests__/
            └── activities.test.ts

Database Tests

Testing with In-Memory Database

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createDatabase, initializeSchema } from '@ohcnetwork/leaderboard-api';
import type { Database } from '@ohcnetwork/leaderboard-api';

describe('Database Tests', () => {
  let db: Database;

  beforeEach(async () => {
    db = createDatabase(':memory:');
    await initializeSchema(db);
  });

  afterEach(async () => {
    await db.close();
  });

  it('should insert and retrieve data', async () => {
    // Test implementation
  });
});

Query Tests

Test database query helpers:

import { contributorQueries } from '@ohcnetwork/leaderboard-api';

it('should get contributor by username', async () => {
  await contributorQueries.upsert(db, {
    username: 'alice',
    name: 'Alice Smith',
    // ... other fields
  });

  const contributor = await contributorQueries.getByUsername(db, 'alice');
  
  expect(contributor).not.toBeNull();
  expect(contributor?.name).toBe('Alice Smith');
});

Schema Tests

Verify database schema:

it('should create all tables', async () => {
  const result = await db.execute(`
    SELECT name FROM sqlite_master 
    WHERE type='table'
    ORDER BY name
  `);

  const tables = result.rows.map(r => r.name);
  expect(tables).toContain('contributor');
  expect(tables).toContain('activity');
  expect(tables).toContain('activity_definition');
});

Plugin Runner Tests

Import Tests

Test data import functionality:

import { importContributors } from '../importers/contributors';
import { mkdir, writeFile, rm } from 'fs/promises';
import matter from 'gray-matter';

const TEST_DIR = './test-data';

beforeEach(async () => {
  await mkdir(join(TEST_DIR, 'contributors'), { recursive: true });
});

afterEach(async () => {
  await rm(TEST_DIR, { recursive: true, force: true });
});

it('should import contributors from markdown', async () => {
  const markdown = matter.stringify('Bio content', {
    username: 'alice',
    name: 'Alice',
  });

  await writeFile(
    join(TEST_DIR, 'contributors', 'alice.md'),
    markdown,
    'utf-8'
  );

  const count = await importContributors(db, TEST_DIR, logger);
  expect(count).toBe(1);
});

Export Tests

Test data export functionality:

import { exportActivities } from '../exporters/activities';
import { readFile } from 'fs/promises';

it('should export activities to JSONL', async () => {
  // Insert test data
  await activityQueries.upsert(db, {
    slug: 'alice-pr-1',
    contributor: 'alice',
    activity_definition: 'pr_merged',
    title: 'Fix bug',
    occured_at: '2024-01-01T10:00:00Z',
    // ... other fields
  });

  await exportActivities(db, TEST_DIR, logger);

  const content = await readFile(
    join(TEST_DIR, 'activities', 'alice.jsonl'),
    'utf-8'
  );

  const lines = content.trim().split('\n');
  expect(lines).toHaveLength(1);

  const activity = JSON.parse(lines[0]);
  expect(activity.slug).toBe('alice-pr-1');
});

Plugin Loader Tests

Test plugin validation:

it('should reject plugin without name', () => {
  const invalidPlugin = {
    version: '1.0.0',
    scrape: async () => {},
  };

  expect(() => validatePlugin(invalidPlugin)).toThrow('name');
});

Configuration Tests

Schema Validation

Test config validation:

import { ConfigSchema } from '../schema';

it('should validate correct config', () => {
  const config = {
    org: {
      name: 'Test Org',
      description: 'Test',
      url: 'https://example.com',
      logo_url: 'https://example.com/logo.png',
    },
    meta: {
      // ... required fields
    },
    leaderboard: {
      roles: {
        core: { name: 'Core' },
      },
    },
  };

  const result = ConfigSchema.safeParse(config);
  expect(result.success).toBe(true);
});

it('should reject invalid URL', () => {
  const config = {
    org: {
      url: 'not-a-url',
      // ... other fields
    },
  };

  const result = ConfigSchema.safeParse(config);
  expect(result.success).toBe(false);
});

Environment Variable Substitution

it('should substitute environment variables', () => {
  process.env.TEST_TOKEN = 'secret';

  const config = {
    plugins: {
      test: {
        config: {
          token: '${{ env.TEST_TOKEN }}',
        },
      },
    },
  };

  const result = substituteEnvVars(config);
  expect(result.plugins.test.config.token).toBe('secret');
});

Data Loading Tests

Test Next.js data loading utilities:

import { getAllContributors, getLeaderboard } from '../loader';

beforeEach(async () => {
  // Set up test database with data
  const db = getDatabase();
  await contributorQueries.upsert(db, testContributor);
});

it('should load all contributors', async () => {
  const contributors = await getAllContributors();
  expect(contributors).toHaveLength(1);
});

it('should load leaderboard rankings', async () => {
  const leaderboard = await getLeaderboard(10);
  expect(leaderboard.length).toBeLessThanOrEqual(10);
  expect(leaderboard[0]).toHaveProperty('total_points');
});

Mocking

Mock Logger

const mockLogger = {
  debug: vi.fn(),
  info: vi.fn(),
  warn: vi.fn(),
  error: vi.fn(),
};

Mock Database

const mockDb = {
  execute: vi.fn().mockResolvedValue({
    rows: [],
    rowsAffected: 0,
  }),
  batch: vi.fn().mockResolvedValue([]),
  close: vi.fn().mockResolvedValue(undefined),
};

Mock Plugin Context

const mockContext = {
  db: mockDb,
  config: { apiKey: 'test' },
  orgConfig: { name: 'Test Org' },
  logger: mockLogger,
};

Test Coverage

Coverage Reports

pnpm test:coverage

Generates reports in coverage/ directory.

Coverage Thresholds

Set in vitest.config.ts:

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'json'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

Continuous Integration

GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: pnpm/action-setup@v2
        with:
          version: 10
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
      
      - run: pnpm install
      
      - run: pnpm test
      
      - run: pnpm test:coverage
      
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

Best Practices

1. Test Isolation

Each test should be independent:

beforeEach(async () => {
  db = createDatabase(':memory:');
  await initializeSchema(db);
});

afterEach(async () => {
  await db.close();
});

2. Descriptive Names

Use clear test descriptions:

it('should import 10 contributors from markdown files', async () => {
  // Test implementation
});

3. Arrange-Act-Assert

Structure tests clearly:

it('should calculate total points', async () => {
  // Arrange
  await activityQueries.upsert(db, activity1);
  await activityQueries.upsert(db, activity2);

  // Act
  const total = await activityQueries.getTotalPoints(db, 'alice');

  // Assert
  expect(total).toBe(25);
});

4. Test Edge Cases

Don't just test the happy path:

it('should handle empty activity list', async () => {
  const activities = await activityQueries.getByContributor(db, 'nonexistent');
  expect(activities).toHaveLength(0);
});

it('should handle malformed JSON in JSONL file', async () => {
  // Test invalid data handling
});

5. Use Test Fixtures

Create reusable test data:

const testContributor = {
  username: 'alice',
  name: 'Alice Smith',
  role: 'core',
  // ... other fields
};

const testActivity = {
  slug: 'test-activity-1',
  contributor: 'alice',
  // ... other fields
};

Performance Testing

Benchmark Tests

import { bench } from 'vitest';

bench('import 1000 activities', async () => {
  await importActivities(db, testDataDir, logger);
});

Load Testing

Test with realistic data volumes:

it('should handle 10000 activities', async () => {
  const activities = generateActivities(10000);
  
  for (const activity of activities) {
    await activityQueries.upsert(db, activity);
  }
  
  const count = await activityQueries.count(db);
  expect(count).toBe(10000);
});

Next Steps