Files
proffesor-for-testing__agen…/docs/guides/timer-testing-patterns.md
Profa b91627f282 fix(quality): implement GOAP Issue #149 code quality remediation
Phase 1: Test Determinism
- Create SeededRandom utility with Mulberry32 PRNG (src/utils/SeededRandom.ts)
- Migrate 258+ Math.random() calls to SeededRandom across 65+ test files
- Add timer test utilities (tests/helpers/timerTestUtils.ts)
- Add jest.useFakeTimers() pattern to 13 test files

Phase 2: Type Safety (63.5% reduction)
- Reduce `any` types from ~1,800 to 656
- Add 50+ new interfaces across agents, core, mcp, cli modules
- Fix all TypeScript errors (0 errors, build passes)
- Replace Record<string, any> with Record<string, unknown>
- Add proper type guards for hook methods (PostTaskData, TaskErrorData)

Key improvements:
- agents/: 105 → 0 any types (100% reduction)
- mcp/: 226 → 102 any types (55% reduction)
- cli/: 277 → 168 any types (39% reduction)
- core/: 199 → 175 any types (12% reduction)

Closes #149

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 17:08:12 +00:00

14 KiB

Timer Testing Patterns: Migrating to jest.useFakeTimers()

This guide provides comprehensive patterns for migrating setTimeout/setInterval based tests to Jest's fake timers for deterministic, reliable testing.

Table of Contents

  1. Why Migrate to Fake Timers
  2. Core Patterns
  3. Advanced Patterns
  4. Helper Utilities
  5. Migration Priority List
  6. Common Pitfalls
  7. Verification Checklist

Why Migrate to Fake Timers

Problems with Real Timers

// PROBLEMATIC: Real timer approach
await new Promise(resolve => setTimeout(resolve, 100));

Issues:

  • Flaky tests: Timing varies with system load
  • Slow tests: Must wait real milliseconds
  • CI instability: Container environments have inconsistent timing
  • Non-deterministic: Same test may pass/fail randomly
  • Resource waste: Blocking test execution

Benefits of Fake Timers

// RECOMMENDED: Fake timer approach
jest.useFakeTimers();
jest.advanceTimersByTime(100);

Advantages:

  • Deterministic: Always executes in exact order
  • Fast: No actual waiting
  • Reliable: Consistent across all environments
  • Controlled: Precise time manipulation

Core Patterns

Pattern 1: Simple Timeout Replacement

Before (Flaky):

it('should expire cache after TTL', async () => {
  cache.set('key', 'value', 100); // 100ms TTL
  await new Promise(resolve => setTimeout(resolve, 150));
  expect(cache.get('key')).toBeNull();
});

After (Deterministic):

it('should expire cache after TTL', () => {
  jest.useFakeTimers();

  cache.set('key', 'value', 100); // 100ms TTL
  jest.advanceTimersByTime(150);

  expect(cache.get('key')).toBeNull();

  jest.useRealTimers();
});

Pattern 2: Multiple Time Advances

Before (Flaky):

it('should handle sequential timeouts', async () => {
  const results: number[] = [];

  setTimeout(() => results.push(1), 50);
  setTimeout(() => results.push(2), 100);

  await new Promise(resolve => setTimeout(resolve, 150));
  expect(results).toEqual([1, 2]);
});

After (Deterministic):

it('should handle sequential timeouts', () => {
  jest.useFakeTimers();
  const results: number[] = [];

  setTimeout(() => results.push(1), 50);
  setTimeout(() => results.push(2), 100);

  jest.advanceTimersByTime(50);
  expect(results).toEqual([1]);

  jest.advanceTimersByTime(50);
  expect(results).toEqual([1, 2]);

  jest.useRealTimers();
});

Pattern 3: Async Operations with Timers

When dealing with promises that resolve after timeouts:

Before (Flaky):

it('should wait for async operation', async () => {
  const result = await someAsyncWithTimeout();
  expect(result).toBe('success');
});

After (Deterministic):

it('should wait for async operation', async () => {
  jest.useFakeTimers();

  const promise = someAsyncWithTimeout();

  // Run all timers including nested ones
  jest.runAllTimers();

  const result = await promise;
  expect(result).toBe('success');

  jest.useRealTimers();
});

Pattern 4: Suite-Level Setup/Teardown

For test suites with many timer-based tests:

describe('TimerBasedFeature', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  it('test 1', () => {
    // Fake timers already active
    jest.advanceTimersByTime(100);
  });

  it('test 2', () => {
    // Fresh fake timer state
    jest.advanceTimersByTime(200);
  });
});

Advanced Patterns

Pattern 5: Retry Logic with Backoff

Before (Flaky):

it('should retry with exponential backoff', async () => {
  const fn = jest.fn()
    .mockRejectedValueOnce(new Error('fail'))
    .mockRejectedValueOnce(new Error('fail'))
    .mockResolvedValue('success');

  const result = await retryWithBackoff(fn, { maxRetries: 3, baseDelay: 100 });

  // Wait for backoff: 100ms + 200ms = 300ms total
  await new Promise(resolve => setTimeout(resolve, 350));

  expect(result).toBe('success');
  expect(fn).toHaveBeenCalledTimes(3);
});

After (Deterministic):

it('should retry with exponential backoff', async () => {
  jest.useFakeTimers();

  const fn = jest.fn()
    .mockRejectedValueOnce(new Error('fail'))
    .mockRejectedValueOnce(new Error('fail'))
    .mockResolvedValue('success');

  const resultPromise = retryWithBackoff(fn, { maxRetries: 3, baseDelay: 100 });

  // First retry after 100ms
  await jest.advanceTimersByTimeAsync(100);

  // Second retry after 200ms (exponential)
  await jest.advanceTimersByTimeAsync(200);

  const result = await resultPromise;

  expect(result).toBe('success');
  expect(fn).toHaveBeenCalledTimes(3);

  jest.useRealTimers();
});

Pattern 6: Interval-Based Polling

Before (Flaky):

it('should poll until condition met', async () => {
  let counter = 0;
  const interval = setInterval(() => counter++, 100);

  await new Promise(resolve => setTimeout(resolve, 350));

  clearInterval(interval);
  expect(counter).toBe(3);
});

After (Deterministic):

it('should poll until condition met', () => {
  jest.useFakeTimers();

  let counter = 0;
  const interval = setInterval(() => counter++, 100);

  jest.advanceTimersByTime(100);
  expect(counter).toBe(1);

  jest.advanceTimersByTime(100);
  expect(counter).toBe(2);

  jest.advanceTimersByTime(100);
  expect(counter).toBe(3);

  clearInterval(interval);
  jest.useRealTimers();
});

Pattern 7: Debounce/Throttle Testing

Before (Flaky):

it('should debounce function calls', async () => {
  const fn = jest.fn();
  const debouncedFn = debounce(fn, 100);

  debouncedFn();
  debouncedFn();
  debouncedFn();

  await new Promise(resolve => setTimeout(resolve, 150));

  expect(fn).toHaveBeenCalledTimes(1);
});

After (Deterministic):

it('should debounce function calls', () => {
  jest.useFakeTimers();

  const fn = jest.fn();
  const debouncedFn = debounce(fn, 100);

  debouncedFn();
  debouncedFn();
  debouncedFn();

  // Before debounce completes
  jest.advanceTimersByTime(50);
  expect(fn).not.toHaveBeenCalled();

  // After debounce completes
  jest.advanceTimersByTime(100);
  expect(fn).toHaveBeenCalledTimes(1);

  jest.useRealTimers();
});

Pattern 8: Mixed Async/Timer Operations

When operations mix promises and timers:

it('should handle mixed async and timer operations', async () => {
  jest.useFakeTimers();

  const results: string[] = [];

  // Start async operation that uses timers internally
  const asyncOp = async () => {
    results.push('start');
    await new Promise(resolve => setTimeout(resolve, 100));
    results.push('after-timeout');
    return 'done';
  };

  const promise = asyncOp();

  // Flush microtasks to process the first push
  await Promise.resolve();
  expect(results).toEqual(['start']);

  // Advance time and flush microtasks
  await jest.advanceTimersByTimeAsync(100);

  const result = await promise;
  expect(results).toEqual(['start', 'after-timeout']);
  expect(result).toBe('done');

  jest.useRealTimers();
});

Pattern 9: Testing Timeout Errors

it('should throw timeout error after deadline', async () => {
  jest.useFakeTimers();

  const operationWithTimeout = async (timeoutMs: number) => {
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('Timeout')), timeoutMs);

      // Simulate operation that never completes
      // In real code, this would resolve on success
    });
  };

  const promise = operationWithTimeout(5000);

  // Advance to just before timeout
  jest.advanceTimersByTime(4999);
  // Promise should still be pending

  // Advance past timeout
  jest.advanceTimersByTime(2);

  await expect(promise).rejects.toThrow('Timeout');

  jest.useRealTimers();
});

Pattern 10: Date.now() Mocking

When code uses Date.now() for timing:

it('should calculate elapsed time correctly', () => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2024-01-01T00:00:00Z'));

  const start = Date.now();

  jest.advanceTimersByTime(5000);

  const elapsed = Date.now() - start;
  expect(elapsed).toBe(5000);

  jest.useRealTimers();
});

Helper Utilities

Use the helper utilities in /workspaces/agentic-qe/tests/helpers/timerTestUtils.ts:

withFakeTimers()

Automatically manages fake timer setup and teardown:

import { withFakeTimers } from '../helpers/timerTestUtils';

it('should test with fake timers', async () => {
  await withFakeTimers(async (timers) => {
    const cache = new Cache({ ttl: 100 });
    cache.set('key', 'value');

    timers.advance(150);

    expect(cache.get('key')).toBeNull();
  });
});

advanceAndFlush()

Advances time and flushes pending promises:

import { advanceAndFlush } from '../helpers/timerTestUtils';

it('should advance and flush', async () => {
  jest.useFakeTimers();

  const promise = asyncOperationWithTimeout();

  await advanceAndFlush(100);

  const result = await promise;
  expect(result).toBeDefined();

  jest.useRealTimers();
});

runAllTimersAsync()

Runs all timers with proper async handling:

import { runAllTimersAsync } from '../helpers/timerTestUtils';

it('should run all timers', async () => {
  jest.useFakeTimers();

  const results: number[] = [];
  setTimeout(() => results.push(1), 100);
  setTimeout(() => results.push(2), 200);

  await runAllTimersAsync();

  expect(results).toEqual([1, 2]);

  jest.useRealTimers();
});

Migration Priority List

Top 10 Files Requiring Migration

Based on analysis of the codebase, these files have the highest setTimeout usage and should be migrated first:

Priority File setTimeout Count Reason
1 tests/integration/learning/idle-detector.test.ts 14 High count, CI flaky
2 tests/integration/agentdb-quic-sync.test.ts 10 High count, long delays
3 tests/unit/batch-operations.test.ts 9 High count, affects CI
4 tests/unit/Agent.test.ts 9 Core agent tests
5 tests/agents/context/ContextCache.test.ts 9 TTL-based tests
6 tests/integration/multi-agent-workflows.test.ts 7 Integration critical
7 tests/monitoring/ProviderHealthMonitor.test.ts 6 Health monitoring
8 tests/integration/learning/learning-engine-persistence.test.ts 5 Learning system
9 tests/memory/PatternReplicationService.test.ts 4 Replication tests
10 tests/voting/orchestrator.test.ts 4 Orchestration timing

Files Already Using Fake Timers (Reference Examples)

These files demonstrate correct fake timer usage:

  • tests/monitoring/QuotaManager.test.ts - Suite-level setup
  • tests/cli/providers.test.ts - Per-test setup
  • tests/providers/LLMProviderFactory.test.ts - Time advancement
  • tests/mcp/handlers/memory/memory-store.test.ts - TTL testing
  • tests/mcp/handlers/memory/consensus-propose.test.ts - Consensus timing

Common Pitfalls

Pitfall 1: Forgetting to Restore Real Timers

// BAD: Leaks fake timers to other tests
it('test', () => {
  jest.useFakeTimers();
  // ... test code
  // Missing: jest.useRealTimers()
});

// GOOD: Always restore
it('test', () => {
  jest.useFakeTimers();
  try {
    // ... test code
  } finally {
    jest.useRealTimers();
  }
});

// BETTER: Use beforeEach/afterEach
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

Pitfall 2: Not Handling Pending Promises

// BAD: Promise never resolves
it('test', async () => {
  jest.useFakeTimers();
  const promise = asyncWithTimeout();
  jest.advanceTimersByTime(100);
  // Promise still pending!
  const result = await promise; // Hangs
});

// GOOD: Use async variant
it('test', async () => {
  jest.useFakeTimers();
  const promise = asyncWithTimeout();
  await jest.advanceTimersByTimeAsync(100);
  const result = await promise;
});

Pitfall 3: Mixing Real and Fake Operations

// BAD: fetch() uses real timers
it('test', async () => {
  jest.useFakeTimers();
  const data = await fetch('/api'); // Times out!
});

// GOOD: Mock external dependencies
it('test', async () => {
  jest.useFakeTimers();
  jest.spyOn(global, 'fetch').mockResolvedValue(mockResponse);
  const data = await fetch('/api');
});

Pitfall 4: Incorrect Time Calculation

// BAD: Accumulated time not considered
jest.advanceTimersByTime(50);
jest.advanceTimersByTime(50);
// Total: 100ms, not 50ms!

// GOOD: Be explicit about total time
jest.advanceTimersByTime(100); // 100ms total
// OR
let currentTime = 0;
currentTime += 50; jest.advanceTimersByTime(50);
currentTime += 50; jest.advanceTimersByTime(50);
// currentTime = 100ms

Pitfall 5: Not Clearing Timers Between Tests

// BAD: Timer from previous test fires
it('test1', () => {
  jest.useFakeTimers();
  setTimeout(() => throw new Error('boom'), 1000);
  // Test ends without advancing time
  jest.useRealTimers();
});

it('test2', () => {
  // Timer from test1 might fire here!
});

// GOOD: Clear pending timers
afterEach(() => {
  jest.clearAllTimers();
  jest.useRealTimers();
});

Verification Checklist

Before marking a migration complete, verify:

  • Test passes consistently (run 10+ times)
  • Test runs in < 100ms (no real waiting)
  • jest.useRealTimers() called in afterEach/finally
  • No console warnings about unhandled timers
  • Works in isolation (npm test -- --testPathPattern=file.test.ts)
  • Works with full suite (npm run test:unit)
  • CI passes with the changes
  • No timing-related flakiness in PR checks

Quick Reference

Method Use When
jest.useFakeTimers() Start using fake timers
jest.useRealTimers() Restore real timers
jest.advanceTimersByTime(ms) Advance by specific milliseconds
jest.advanceTimersByTimeAsync(ms) Advance with promise flushing
jest.runAllTimers() Run all pending timers
jest.runOnlyPendingTimers() Run only currently queued timers
jest.clearAllTimers() Clear pending timers without running
jest.setSystemTime(date) Set Date.now() value
jest.getRealSystemTime() Get actual system time