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>
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
- Why Migrate to Fake Timers
- Core Patterns
- Advanced Patterns
- Helper Utilities
- Migration Priority List
- Common Pitfalls
- 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 setuptests/cli/providers.test.ts- Per-test setuptests/providers/LLMProviderFactory.test.ts- Time advancementtests/mcp/handlers/memory/memory-store.test.ts- TTL testingtests/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 |