Back to catalog

test-engineer

Skill from apettey/BattleScope

  • Category: discovered
  • Tags: github, community
  • Version: 1.0.0
  • Author: walid
  • Downloads: 0
  • Updated: 2025-11-22

Download the latest version (v1.0.0) from this page.

SKILL.md

# Test Engineer

Ensure comprehensive test coverage (80%+) for all BattleScope backend services with high-quality, illustrative test cases.

## Purpose

This skill helps you create, maintain, and verify comprehensive test suites for backend services, ensuring code quality, reliability, and maintainability through systematic testing practices.

## When to Use

Invoke this skill when:
- Creating a new backend service
- Adding new features to existing services
- Refactoring code without breaking functionality
- Fixing bugs (write tests first)
- Reviewing test coverage
- Setting up CI/CD testing pipelines
- Investigating test failures

## Testing Standards

### Coverage Requirements

**Minimum Coverage: 80%**

| Service Type | Coverage Target | Priority Areas |
|-------------|-----------------|----------------|
| API Service | 85%+ | Routes, middleware, auth, validation |
| Worker Services | 80%+ | Job handlers, error handling, retries |
| Background Services | 80%+ | Core logic, state management |
| Shared Packages | 90%+ | Utilities, clients, core functions |

### Coverage Exclusions
- Configuration files
- Type definitions
- Entry points (index.ts with minimal logic)
- Generated code
- Migration scripts

## Test Types

### 1. Unit Tests (60% of tests)
**Purpose**: Test individual functions and classes in isolation

**Coverage Target**: All business logic, utilities, validators

**Example**:
```typescript
// src/utils/battle-calculator.ts
export function calculateBattleValue(killmails: Killmail[]): number {
  return killmails.reduce((sum, km) => sum + km.totalValue, 0);
}

// test/utils/battle-calculator.test.ts
import { describe, it, expect } from 'vitest';
import { calculateBattleValue } from '../../src/utils/battle-calculator.js';

describe('calculateBattleValue', () => {
  it('should calculate total value of killmails', () => {
    const killmails = [
      { totalValue: 1000 },
      { totalValue: 2000 },
      { totalValue: 3000 },
    ];

    expect(calculateBattleValue(killmails)).toBe(6000);
  });

  it('should return 0 for empty array', () => {
    expect(calculateBattleValue([])).toBe(0);
  });

  it('should handle single killmail', () => {
    const killmails = [{ totalValue: 5000 }];
    expect(calculateBattleValue(killmails)).toBe(5000);
  });

  it('should handle negative values', () => {
    const killmails = [
      { totalValue: 1000 },
      { totalValue: -500 },
    ];
    expect(calculateBattleValue(killmails)).toBe(500);
  });
});
```

### 2. Integration Tests (30% of tests)
**Purpose**: Test interactions between components

**Coverage Target**: Database queries, API clients, service interactions

**Example**:
```typescript
// test/integration/battle-repository.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestDatabase } from '../helpers/test-database.js';
import { BattleRepository } from '../../src/repositories/battle-repository.js';

describe('BattleRepository Integration', () => {
  let db: TestDatabase;
  let repository: BattleRepository;

  beforeAll(async () => {
    db = await createTestDatabase();
    repository = new BattleRepository(db.client);
  });

  afterAll(async () => {
    await db.cleanup();
  });

  describe('createBattle', () => {
    it('should insert battle and return ID', async () => {
      const battle = {
        startTime: new Date('2024-01-01T12:00:00Z'),
        endTime: new Date('2024-01-01T12:30:00Z'),
        systemId: 30000142,
      };

      const battleId = await repository.createBattle(battle);

      expect(battleId).toBeDefined();
      expect(typeof battleId).toBe('string');

      // Verify it was actually inserted
      const retrieved = await repository.getBattle(battleId);
      expect(retrieved).toMatchObject(battle);
    });

    it('should throw error for invalid systemId', async () => {
      const battle = {
        startTime: new Date(),
        endTime: new Date(),
        systemId: -1, // Invalid
      };

      await expect(repository.createBattle(battle))
        .rejects
        .toThrow('Invalid system ID');
    });
  });

  describe('getBattlesByDateRange', () => {
    it('should return battles within date range', async () => {
      // Setup: Create test data
      await repository.createBattle({
        startTime: new Date('2024-01-01T12:00:00Z'),
        endTime: new Date('2024-01-01T12:30:00Z'),
        systemId: 30000142,
      });

      await repository.createBattle({
        startTime: new Date('2024-01-05T12:00:00Z'),
        endTime: new Date('2024-01-05T12:30:00Z'),
        systemId: 30000142,
      });

      // Test
      const battles = await repository.getBattlesByDateRange(
        new Date('2024-01-01'),
        new Date('2024-01-02')
      );

      expect(battles).toHaveLength(1);
      expect(battles[0].startTime.getDate()).toBe(1);
    });

    it('should return empty array when no battles in range', async () => {
      const battles = await repository.getBattlesByDateRange(
        new Date('2024-12-01'),
        new Date('2024-12-02')
      );

      expect(battles).toHaveLength(0);
    });
  });
});
```

### 3. End-to-End Tests (10% of tests)
**Purpose**: Test complete user workflows

**Coverage Target**: Critical user paths, API endpoints

**Example**:
```typescript
// test/e2e/battle-creation-flow.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestServer } from '../helpers/test-server.js';
import type { TestServer } from '../helpers/test-server.js';

describe('Battle Creation E2E', () => {
  let server: TestServer;

  beforeAll(async () => {
    server = await setupTestServer();
  });

  afterAll(async () => {
    await server.cleanup();
  });

  it('should complete full battle creation flow', async () => {
    // 1. Ingest killmail
    const killmail = await server.ingestKillmail({
      killmail_id: 123456,
      killmail_time: '2024-01-01T12:00:00Z',
      solar_system_id: 30000142,
      victim: { character_id: 12345 },
      attackers: [{ character_id: 67890 }],
    });

    expect(killmail).toMatchObject({ status: 'queued' });

    // 2. Wait for enrichment
    await server.waitForJobCompletion('enrichment', killmail.id);

    // 3. Trigger clustering
    await server.triggerClustering();

    // 4. Verify battle was created
    const battles = await server.api.get('/api/battles');
    expect(battles.data).toHaveLength(1);

    const battle = battles.data[0];
    expect(battle.killmailIds).toContain(killmail.id);
    expect(battle.systemId).toBe(30000142);

    // 5. Retrieve battle details
    const details = await server.api.get(`/api/battles/${battle.id}`);
    expect(details.data.statistics).toBeDefined();
    expect(details.data.statistics.totalValue).toBeGreaterThan(0);
  });
});
```

## Test Organization

### Directory Structure

```
<service>/
├── src/
│   ├── index.ts
│   ├── routes/
│   ├── services/
│   ├── repositories/
│   └── utils/
└── test/
    ├── unit/                    # Unit tests mirror src/
    │   ├── routes/
    │   ├── services/
    │   ├── repositories/
    │   └── utils/
    ├── integration/             # Integration tests
    │   ├── database/
    │   ├── api-client/
    │   └── redis/
    ├── e2e/                     # End-to-end tests
    │   └── workflows/
    ├── helpers/                 # Test utilities
    │   ├── factories.ts         # Test data factories
    │   ├── fixtures.ts          # Static test data
    │   ├── mocks.ts             # Mock implementations
    │   └── test-database.ts     # Database test setup
    └── setup.ts                 # Global test setup
```

### Naming Conventions

- Test files: `<module-name>.test.ts`
- Test helpers: `<helper-name>.ts` (no .test suffix)
- Factories: `<entity>-factory.ts`
- Fixtures: `<entity>-fixtures.ts`

## Test Implementation Guide

### Step 1: Set Up Test Infrastructure

#### Configure Vitest

**File**: `<service>/vitest.config.ts`

```typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        'dist/**',
        'test/**',
        '**/*.config.ts',
        '**/index.ts',
        '**/*.d.ts',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
    setupFiles: ['./test/setup.ts'],
    testTimeout: 10000,
    hookTimeout: 10000,
  },
});
```

#### Create Test Setup

**File**: `<service>/test/setup.ts`

```typescript
import { beforeAll, afterAll, afterEach } from 'vitest';
import { setupTestDatabase } from './helpers/test-database.js';
import { setupTestRedis } from './helpers/test-redis.js';

// Global test setup
let testDb: any;
let testRedis: any;

beforeAll(async () => {
  // Set test environment
  process.env.NODE_ENV = 'test';
  process.env.LOG_LEVEL = 'silent';

  // Setup test database
  testDb = await setupTestDatabase();

  // Setup test Redis
  testRedis = await setupTestRedis();
});

afterEach(async () => {
  // Clean up after each test
  if (testDb) {
    await testDb.clearAllTables();
  }
  if (testRedis) {
    await testRedis.flushAll();
  }
});

afterAll(async () => {
  // Cleanup connections
  if (testDb) {
    await testDb.destroy();
  }
  if (testRedis) {
    await testRedis.quit();
  }
});
```

### Step 2: Create Test Helpers

#### Database Helper

**File**: `<service>/test/helpers/test-database.ts`

```typescript
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import type { Database } from '../../src/types/database.js';

export interface TestDatabase {
  client: Kysely<Database>;
  clearAllTables: () => Promise<void>;
  destroy: () => Promise<void>;
}

export async function createTestDatabase(): Promise<TestDatabase> {
  const pool = new Pool({
    host: process.env.TEST_POSTGRES_HOST || 'localhost',
    port: Number(process.env.TEST_POSTGRES_PORT) || 5432,
    database: process.env.TEST_POSTGRES_DB || 'battlescope_test',
    user: process.env.TEST_POSTGRES_USER || 'postgres',
    password: process.env.TEST_POSTGRES_PASSWORD || 'postgres',
  });

  const client = new Kysely<Database>({
    dialect: new PostgresDialect({ pool }),
  });

  // Run migrations
  // await migrateToLatest(client);

  return {
    client,
    async clearAllTables() {
      // Delete in correct order to respect foreign keys
      await client.deleteFrom('battle_participants').execute();
      await client.deleteFrom('battles').execute();
      await client.deleteFrom('killmails').execute();
      // ... other tables
    },
    async destroy() {
      await client.destroy();
    },
  };
}
```

#### Factory Helper

**File**: `<service>/test/helpers/factories.ts`

```typescript
import { Factory } from 'fishery';
import type { Battle, Killmail } from '../../src/types/database.js';

// Battle factory
export const battleFactory = Factory.define<Battle>(({ sequence }) => ({
  id: `battle-${sequence}`,
  startTime: new Date('2024-01-01T12:00:00Z'),
  endTime: new Date('2024-01-01T12:30:00Z'),
  systemId: 30000142,
  systemName: 'Jita',
  killmailCount: 10,
  totalValue: 1000000000,
  createdAt: new Date(),
  updatedAt: new Date(),
}));

// Killmail factory
export const killmailFactory = Factory.define<Killmail>(({ sequence }) => ({
  id: `km-${sequence}`,
  zkbId: sequence,
  killTime: new Date('2024-01-01T12:00:00Z'),
  systemId: 30000142,
  victimCharacterId: 12345,
  victimCorporationId: 98765,
  victimAllianceId: 11111,
  victimShipTypeId: 587,
  totalValue: 100000000,
  rawData: {},
  createdAt: new Date(),
}));

// Usage in tests:
// const battle = battleFactory.build();
// const battles = battleFactory.buildList(5);
// const customBattle = battleFactory.build({ systemId: 30000143 });
```

#### Mock Helper

**File**: `<service>/test/helpers/mocks.ts`

```typescript
import { vi } from 'vitest';
import type { EsiClient } from '@battlescope/esi-client';

export function createMockEsiClient(): EsiClient {
  return {
    getKillmail: vi.fn().mockResolvedValue({
      killmail_id: 123456,
      killmail_time: '2024-01-01T12:00:00Z',
      victim: { character_id: 12345 },
      attackers: [],
    }),
    getCharacter: vi.fn().mockResolvedValue({
      character_id: 12345,
      name: 'Test Character',
      corporation_id: 98765,
    }),
    // ... other methods
  } as any;
}

export function createMockLogger() {
  return {
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
    trace: vi.fn(),
    fatal: vi.fn(),
  };
}
```

### Step 3: Write Comprehensive Tests

#### Example: Service with Full Coverage

**File**: `src/services/battle-clusterer.ts`

```typescript
export class BattleClusterer {
  constructor(
    private readonly db: Kysely<Database>,
    private readonly config: ClusterConfig,
  ) {}

  async clusterKillmails(killmails: Killmail[]): Promise<Battle[]> {
    if (killmails.length === 0) {
      return [];
    }

    // Sort by time
    const sorted = [...killmails].sort(
      (a, b) => a.killTime.getTime() - b.killTime.getTime()
    );

    const clusters: Killmail[][] = [];
    let currentCluster: Killmail[] = [sorted[0]];

    for (let i = 1; i < sorted.length; i++) {
      const prev = sorted[i - 1];
      const curr = sorted[i];

      const timeDiff = curr.killTime.getTime() - prev.killTime.getTime();
      const maxGap = this.config.maxGapMinutes * 60 * 1000;

      if (
        timeDiff <= maxGap &&
        curr.systemId === prev.systemId
      ) {
        currentCluster.push(curr);
      } else {
        if (currentCluster.length >= this.config.minKillmails) {
          clusters.push(currentCluster);
        }
        currentCluster = [curr];
      }
    }

    // Don't forget last cluster
    if (currentCluster.length >= this.config.minKillmails) {
      clusters.push(currentCluster);
    }

    // Create battles
    const battles = await Promise.all(
      clusters.map(cluster => this.createBattle(cluster))
    );

    return battles;
  }

  private async createBattle(killmails: Killmail[]): Promise<Battle> {
    // Implementation...
  }
}
```

**File**: `test/unit/services/battle-clusterer.test.ts`

```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { BattleClusterer } from '../../../src/services/battle-clusterer.js';
import { createMockDatabase } from '../../helpers/mocks.js';
import { killmailFactory } from '../../helpers/factories.js';

describe('BattleClusterer', () => {
  let clusterer: BattleClusterer;
  let mockDb: any;

  beforeEach(() => {
    mockDb = createMockDatabase();
    clusterer = new BattleClusterer(mockDb, {
      maxGapMinutes: 10,
      minKillmails: 3,
    });
  });

  describe('clusterKillmails', () => {
    it('should return empty array for no killmails', async () => {
      const result = await clusterer.clusterKillmails([]);
      expect(result).toEqual([]);
    });

    it('should create single cluster for consecutive killmails', async () => {
      const killmails = [
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:00:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:05:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:08:00Z'),
          systemId: 30000142,
        }),
      ];

      const result = await clusterer.clusterKillmails(killmails);

      expect(result).toHaveLength(1);
      expect(result[0].killmailCount).toBe(3);
    });

    it('should split clusters when gap exceeds maxGapMinutes', async () => {
      const killmails = [
        // Cluster 1
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:00:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:05:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:08:00Z'),
          systemId: 30000142,
        }),
        // Gap of 15 minutes
        // Cluster 2
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:25:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:28:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:30:00Z'),
          systemId: 30000142,
        }),
      ];

      const result = await clusterer.clusterKillmails(killmails);

      expect(result).toHaveLength(2);
      expect(result[0].killmailCount).toBe(3);
      expect(result[1].killmailCount).toBe(3);
    });

    it('should split clusters when system changes', async () => {
      const killmails = [
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:00:00Z'),
          systemId: 30000142, // Jita
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:05:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:08:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:09:00Z'),
          systemId: 30000143, // Different system
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:10:00Z'),
          systemId: 30000143,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:11:00Z'),
          systemId: 30000143,
        }),
      ];

      const result = await clusterer.clusterKillmails(killmails);

      expect(result).toHaveLength(2);
      expect(result[0].systemId).toBe(30000142);
      expect(result[1].systemId).toBe(30000143);
    });

    it('should ignore clusters below minKillmails threshold', async () => {
      const killmails = [
        // Only 2 killmails (below threshold of 3)
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:00:00Z'),
          systemId: 30000142,
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:05:00Z'),
          systemId: 30000142,
        }),
      ];

      const result = await clusterer.clusterKillmails(killmails);

      expect(result).toHaveLength(0);
    });

    it('should handle killmails in random order', async () => {
      const killmails = [
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:08:00Z'),
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:00:00Z'),
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:05:00Z'),
        }),
      ];

      const result = await clusterer.clusterKillmails(killmails);

      // Should still create cluster after sorting
      expect(result).toHaveLength(1);
    });

    it('should handle edge case of exactly maxGapMinutes', async () => {
      const killmails = [
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:00:00Z'),
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:10:00Z'), // Exactly 10 minutes
        }),
        killmailFactory.build({
          killTime: new Date('2024-01-01T12:15:00Z'),
        }),
      ];

      const result = await clusterer.clusterKillmails(killmails);

      // Should be in same cluster
      expect(result).toHaveLength(1);
      expect(result[0].killmailCount).toBe(3);
    });
  });
});
```

### Step 4: Run and Verify Coverage

```bash
# Run all tests
pnpm --filter @battlescope/<service> test

# Run tests with coverage
pnpm --filter @battlescope/<service> test --coverage

# Run specific test file
pnpm --filter @battlescope/<service> test path/to/file.test.ts

# Run tests in watch mode
pnpm --filter @ba