Claude Code Plugins

Community-maintained marketplace

Feedback

tzurot-testing

@lbds137/tzurot
6
0

Comprehensive testing patterns for Tzurot v3 - Vitest configuration, fake timers, promise handling, mocking strategies, and test organization. Use this when writing or modifying tests.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name tzurot-testing
description Comprehensive testing patterns for Tzurot v3 - Vitest configuration, fake timers, promise handling, mocking strategies, and test organization. Use this when writing or modifying tests.
lastUpdated 2025-11-19

Tzurot v3 Testing Patterns

Use this skill when: Writing tests, debugging test failures, or adding test coverage to Tzurot v3 codebase.

Core Principles

  1. Test behavior, not implementation - Focus on WHAT code does, not HOW
  2. Test public APIs only - Never test private methods or internal state
  3. Mock all external dependencies - Discord.js, Redis, database, AI providers
  4. Use fake timers - Never use real timeouts or delays in tests
  5. Colocated tests - Test files live next to source files (e.g., MyService.test.ts next to MyService.ts)

Test File Organization

Standard Structure (Colocated)

src/
├── services/
│   ├── MyService.ts
│   ├── MyService.test.ts        # ← Test next to source
│   ├── AnotherService.ts
│   └── AnotherService.test.ts
└── utils/
    ├── helper.ts
    └── helper.test.ts

Exception: Integration tests that span multiple modules can live in src/test/ directories.

Build Configuration

Ensure tsconfig.json excludes test files:

{
  "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"]
}

Vitest Patterns

Basic Test Structure

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('MyService', () => {
  let service: MyService;
  let mockDependency: MockedDependency;

  beforeEach(() => {
    // Setup mocks
    mockDependency = createMockDependency();
    service = new MyService(mockDependency);
  });

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

  describe('methodName', () => {
    it('should do the expected behavior', () => {
      // Arrange
      const input = { /* test data */ };

      // Act
      const result = service.methodName(input);

      // Assert
      expect(result).toEqual(expectedOutput);
    });
  });
});

Fake Timers

Always Use Fake Timers for Time-Based Code

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('time-based operations', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

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

  it('should retry with exponential backoff', async () => {
    const fn = vi.fn()
      .mockRejectedValueOnce(new Error('Fail 1'))
      .mockRejectedValueOnce(new Error('Fail 2'))
      .mockResolvedValueOnce('success');

    // Start the async operation
    const promise = withRetry(fn, { maxAttempts: 3, initialDelayMs: 100 });

    // Advance timers to trigger retries
    await vi.runAllTimersAsync();

    // Await the result
    const result = await promise;
    expect(result).toBe('success');
    expect(fn).toHaveBeenCalledTimes(3);
  });
});

Promise Rejection Handling with Fake Timers

CRITICAL PATTERN: When testing code that rejects promises after timer delays, attach assertion handlers BEFORE advancing timers to avoid unhandled rejection warnings.

The Problem

// ❌ WRONG - Causes PromiseRejectionHandledWarning
it('should throw error after timeout', async () => {
  const promise = asyncFunction();

  await vi.runAllTimersAsync(); // ❌ Rejection happens here, no handler attached yet

  await expect(promise).rejects.toThrow(); // ❌ Handler attached too late
});

Why it fails: Race condition between timer advancement and handler attachment:

  1. const promise = asyncFunction() - Creates promise (no handler)
  2. await vi.runAllTimersAsync() - Advances timers, triggers rejection
  3. Promise rejects with NO handler → Warning issued
  4. await expect(promise).rejects... - Handler attached too late

The Solution

// ✅ CORRECT - Attach handler BEFORE advancing timers
it('should throw error after timeout', async () => {
  const error = new Error('Timeout');
  const fn = vi.fn().mockRejectedValue(error);

  // 1. Create the promise
  const promise = withRetry(fn, { maxAttempts: 3, initialDelayMs: 100 });

  // 2. Attach handler BEFORE advancing timers
  const assertionPromise = expect(promise).rejects.toThrow('Timeout');

  // 3. NOW advance the timers
  await vi.runAllTimersAsync();

  // 4. Await the assertion
  await assertionPromise;

  // 5. Additional assertions
  expect(fn).toHaveBeenCalledTimes(3);
});

Alternative Pattern (Inspecting Error Details)

it('should throw with details', async () => {
  expect.assertions(2);

  try {
    const promise = withRetry(fn, { maxAttempts: 3 });
    await vi.runAllTimersAsync();
    await promise;
  } catch (e: any) {
    expect(e).toBeInstanceOf(RetryError);
    expect(e.attempts).toBe(3);
  }
});

Mocking Patterns

Discord.js Mocking

import type { Message, TextChannel, Guild } from 'discord.js';

function createMockMessage(overrides?: Partial<Message>): Message {
  return {
    id: '123456789',
    content: 'test message',
    author: {
      id: 'user-123',
      username: 'testuser',
      bot: false,
    },
    channel: {
      id: 'channel-123',
      type: 0, // Text channel
      send: vi.fn().mockResolvedValue({}),
    },
    guild: {
      id: 'guild-123',
    },
    reply: vi.fn().mockResolvedValue({}),
    ...overrides,
  } as unknown as Message;
}

BullMQ Mocking

import type { Queue, Job } from 'bullmq';

function createMockQueue(): Queue {
  return {
    add: vi.fn().mockResolvedValue({ id: 'job-123' }),
    getJob: vi.fn().mockResolvedValue(null),
    pause: vi.fn().mockResolvedValue(undefined),
    resume: vi.fn().mockResolvedValue(undefined),
    close: vi.fn().mockResolvedValue(undefined),
  } as unknown as Queue;
}

function createMockJob(data: any): Job {
  return {
    id: 'job-123',
    data,
    progress: vi.fn(),
    updateProgress: vi.fn(),
    log: vi.fn(),
  } as unknown as Job;
}

Redis Mocking

import type { Redis } from 'ioredis';

function createMockRedis(): Redis {
  return {
    get: vi.fn().mockResolvedValue(null),
    set: vi.fn().mockResolvedValue('OK'),
    setEx: vi.fn().mockResolvedValue('OK'),
    del: vi.fn().mockResolvedValue(1),
    ping: vi.fn().mockResolvedValue('PONG'),
    disconnect: vi.fn(),
    duplicate: vi.fn(),
    on: vi.fn(),
  } as unknown as Redis;
}

Prisma Mocking

import type { PrismaClient } from '@prisma/client';

function createMockPrisma(): PrismaClient {
  return {
    personality: {
      findUnique: vi.fn(),
      findMany: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    conversationHistory: {
      create: vi.fn(),
      findMany: vi.fn(),
      deleteMany: vi.fn(),
    },
    $disconnect: vi.fn().mockResolvedValue(undefined),
  } as unknown as PrismaClient;
}

AI Provider Mocking (OpenRouter/Gemini)

interface MockAIResponse {
  content: string;
  model: string;
  usage?: { tokens: number };
}

function createMockAIProvider() {
  return {
    generateResponse: vi.fn().mockResolvedValue({
      content: 'Mock AI response',
      model: 'test-model',
      usage: { tokens: 100 },
    } as MockAIResponse),
    generateEmbedding: vi.fn().mockResolvedValue([0.1, 0.2, 0.3]),
  };
}

Service Testing Patterns

Constructor Injection Pattern

describe('PersonalityService', () => {
  let service: PersonalityService;
  let mockPrisma: PrismaClient;

  beforeEach(() => {
    mockPrisma = createMockPrisma();
    service = new PersonalityService(mockPrisma);
  });

  it('should load personality from database', async () => {
    vi.mocked(mockPrisma.personality.findUnique).mockResolvedValue({
      id: 'test-id',
      name: 'TestPersonality',
      systemPrompt: 'Test prompt',
      // ... other fields
    });

    const personality = await service.getPersonality('test-id');

    expect(personality).toBeDefined();
    expect(personality?.name).toBe('TestPersonality');
    expect(mockPrisma.personality.findUnique).toHaveBeenCalledWith({
      where: { id: 'test-id' },
    });
  });
});

Error Handling Tests

describe('error handling', () => {
  it('should handle database connection errors', async () => {
    const dbError = new Error('Database connection failed');
    vi.mocked(mockPrisma.personality.findUnique).mockRejectedValue(dbError);

    await expect(service.getPersonality('test-id')).rejects.toThrow(
      'Database connection failed'
    );
  });

  it('should handle not found errors', async () => {
    vi.mocked(mockPrisma.personality.findUnique).mockResolvedValue(null);

    const personality = await service.getPersonality('nonexistent');

    expect(personality).toBeNull();
  });
});

Snapshot Testing

Use snapshots sparingly - Only for complex output that's expensive to manually assert.

describe('prompt generation', () => {
  it('should generate correct prompt structure', () => {
    const prompt = generatePrompt({
      personality: 'Lilith',
      context: 'Test context',
      history: [],
    });

    // Snapshot ensures prompt structure doesn't accidentally change
    expect(prompt).toMatchSnapshot();
  });
});

Running Tests

Command Reference

# Run all tests
pnpm test

# Run specific service tests
pnpm --filter @tzurot/ai-worker test

# Run specific test file
pnpm test -- MyService.test.ts

# Check test summary
pnpm test 2>&1 | grep -E "(Test Files|Tests)" | sed 's/\x1b\[[0-9;]*m//g'

# Watch mode (for development)
pnpm test -- --watch

Before Pushing to Remote

ALWAYS run tests before pushing! Even "simple" changes can break tests.

# 1. Run all tests
pnpm test

# 2. Verify summary shows all passing
pnpm test 2>&1 | grep -E "(Test Files|Tests)" | sed 's/\x1b\[[0-9;]*m//g'

# 3. If all green, safe to push
git push origin <branch-name>

Anti-Patterns to Avoid

❌ Don't Test Implementation Details

// ❌ BAD - Testing private methods
expect(service['privateMethod']()).toBe(value);

// ✅ GOOD - Test public behavior
expect(service.publicMethod()).toBe(value);

❌ Don't Use Real Timeouts

// ❌ BAD - Real delay in tests
await new Promise(resolve => setTimeout(resolve, 1000));

// ✅ GOOD - Fake timers
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(1000);

❌ Don't Mock What You Don't Own

// ❌ BAD - Mocking Node.js built-ins directly
vi.mock('fs');

// ✅ GOOD - Wrap in your own abstraction and mock that
class FileSystem {
  readFile(path: string) { /* uses fs */ }
}

❌ Don't Leave Console Logs in Tests

// ❌ BAD - Debugging logs left in
it('should work', () => {
  console.log('Debug:', value); // Remove before commit
  expect(value).toBe(expected);
});

❌ Don't Skip Tests Instead of Fixing

// ❌ BAD - Skipping broken tests
it.skip('should work', () => { /* ... */ });

// ✅ GOOD - Fix or remove the test
it('should work', () => { /* fixed implementation */ });

Test Coverage Goals

  • Services: Aim for >80% coverage
  • Utils: Aim for >90% coverage (these are pure functions)
  • Types/Interfaces: No coverage needed (TypeScript provides type safety)

Related Skills

  • tzurot-constants - Use named constants for test data and timeouts
  • tzurot-observability - Add logging to debug failing tests
  • tzurot-shared-types - Type-safe test fixtures and mocks
  • tzurot-git-workflow - Always run tests before committing/pushing

References

  • Full testing guide: docs/guides/TESTING.md
  • Global testing principles: ~/.claude/CLAUDE.md#universal-testing-philosophy
  • Project testing patterns: CLAUDE.md#testing