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:coveragePackage-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 testTest 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.tsDatabase 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",
occurred_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");
});
it("should validate plugin with aggregate method", () => {
const plugin = {
name: "test-plugin",
version: "1.0.0",
scrape: async () => {},
aggregate: async () => {},
};
expect(typeof plugin.aggregate).toBe("function");
});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:coverageGenerates 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.jsonBest 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);
});