Async Testing

Overview

Fauji provides comprehensive support for testing asynchronous code including promises, async/await patterns, callbacks, timers, and complex async workflows. Whether you're testing API calls, database operations, or time-dependent functionality, Fauji has the tools you need.

Key Async Testing Features

  • Promise Testing - Direct promise resolution and rejection testing
  • Async/Await Support - Native support for async test functions
  • Async Matchers - Specialized matchers for promise-based assertions
  • Fake Timers - Control time-dependent code execution
  • Error Handling - Comprehensive async error testing
  • Timeout Control - Prevent hanging tests with timeouts

Basic Async Test Patterns

Async/Await Pattern (Recommended)

            
describe('Async/Await Testing', () => {
  test('fetches user data successfully', async () => {
    const userData = await fetchUser(123);
    
    expect(userData).toMatchObject({
      id: 123,
      name: expect.toBeString(),
      email: expect.toBeValidEmail()
    });
  });

  test('handles API errors gracefully', async () => {
    // Test that the function throws when user doesn't exist
    await expect(fetchUser(999)).rejects.toThrow('User not found');
  });

  test('processes multiple concurrent requests', async () => {
    const promises = [
      fetchUser(1),
      fetchUser(2),
      fetchUser(3)
    ];
    
    const users = await Promise.all(promises);
    expect(users).toHaveLength(3);
    expect(users.every(user => user.id)).toBeTruthy();
  });
});
            
          

Promise Return Pattern

            
describe('Promise Return Testing', () => {
  test('processes data correctly', () => {
    return processData({ input: 'test' }).then(result => {
      expect(result.processed).toBe(true);
      expect(result.data).toContain('test');
    });
  });

  test('handles processing errors', () => {
    return processData(null).catch(error => {
      expect(error.message).toMatch(/invalid input/i);
    });
  });
});
            
          

Promise Resolution Testing

toResolve(expected?)

Tests that a promise resolves successfully, optionally with a specific value.

            
describe('Promise Resolution', () => {
  test('promise resolves without checking value', async () => {
    await expect(Promise.resolve('any value')).toResolve();
    await expect(Promise.resolve(null)).toResolve();
    await expect(Promise.resolve(undefined)).toResolve();
  });

  test('promise resolves with specific value', async () => {
    await expect(Promise.resolve(42)).toResolve(42);
    await expect(Promise.resolve('hello')).toResolve('hello');
    await expect(Promise.resolve({ id: 1 })).toResolve({ id: 1 });
  });

  test('complex async operations', async () => {
    const asyncOperation = async () => {
      await delay(100);
      return { success: true, data: 'processed' };
    };

    await expect(asyncOperation()).toResolve({
      success: true,
      data: 'processed'
    });
  });
});
            
          

resolves Matcher Chain

Use with any matcher to test resolved values.

            
describe('Resolves Matcher Chain', () => {
  test('chain with various matchers', async () => {
    await expect(Promise.resolve(42)).resolves.toBe(42);
    await expect(Promise.resolve('hello')).resolves.toMatch(/hell/);
    await expect(Promise.resolve([1, 2, 3])).resolves.toHaveLength(3);
    await expect(Promise.resolve([1, 2, 3])).resolves.toContain(2);
  });

  test('complex object resolution', async () => {
    const apiResponse = Promise.resolve({
      user: { id: 1, name: 'John' },
      meta: { total: 100 }
    });

    await expect(apiResponse).resolves.toMatchObject({
      user: { name: 'John' }
    });
    await expect(apiResponse).resolves.toHaveProperty('meta.total', 100);
  });

  test('type checking resolved values', async () => {
    await expect(Promise.resolve([])).resolves.toBeArray();
    await expect(Promise.resolve({})).resolves.toBeObject();
    await expect(Promise.resolve('test')).resolves.toBeString();
    await expect(Promise.resolve(42)).resolves.toBeNumber();
  });
});
            
          

Promise Rejection Testing

toReject(expected?)

Tests that a promise rejects, optionally with a specific error.

            
describe('Promise Rejection', () => {
  test('promise rejects without checking error', async () => {
    await expect(Promise.reject(new Error('any error'))).toReject();
    await expect(Promise.reject('string error')).toReject();
    await expect(Promise.reject(null)).toReject();
  });

  test('promise rejects with specific error', async () => {
    await expect(
      Promise.reject(new Error('Database connection failed'))
    ).toReject('Database connection failed');

    await expect(
      Promise.reject(new TypeError('Invalid argument'))
    ).toReject(TypeError);
  });

  test('async function error handling', async () => {
    const failingFunction = async () => {
      throw new Error('Operation failed');
    };

    await expect(failingFunction()).toReject('Operation failed');
  });
});
            
          

rejects Matcher Chain

Use with error matchers to test rejection reasons.

            
describe('Rejects Matcher Chain', () => {
  test('error message testing', async () => {
    const error = new Error('Network timeout');
    await expect(Promise.reject(error)).rejects.toThrow('Network timeout');
    await expect(Promise.reject(error)).rejects.toThrow(/timeout/);
  });

  test('error type testing', async () => {
    await expect(
      Promise.reject(new TypeError('Invalid type'))
    ).rejects.toThrow(TypeError);

    await expect(
      Promise.reject(new ReferenceError('Undefined variable'))
    ).rejects.toThrow(ReferenceError);
  });

  test('custom error objects', async () => {
    const customError = {
      code: 'AUTH_FAILED',
      message: 'Invalid credentials',
      statusCode: 401
    };

    await expect(Promise.reject(customError)).rejects.toMatchObject({
      code: 'AUTH_FAILED',
      statusCode: 401
    });
  });
});
            
          

Real-World Async Testing Examples

API Testing

            
describe('API Client', () => {
  test('GET request returns user data', async () => {
    const response = await apiClient.get('/users/1');
    
    expect(response).toMatchObject({
      status: 200,
      data: {
        id: 1,
        name: expect.toBeString(),
        email: expect.toBeValidEmail()
      }
    });
  });

  test('POST request creates new user', async () => {
    const newUser = { name: 'John', email: 'john@example.com' };
    const response = await apiClient.post('/users', newUser);

    expect(response.status).toBe(201);
    expect(response.data).toMatchObject({
      id: expect.toBeNumber(),
      ...newUser
    });
  });

  test('handles network errors', async () => {
    // Mock network failure
    mockFetch.mockRejectedValue(new Error('Network Error'));

    await expect(apiClient.get('/users/1')).rejects.toThrow('Network Error');
  });

  test('handles rate limiting', async () => {
    const rateLimitError = new Error('Rate limit exceeded');
    rateLimitError.status = 429;

    mockFetch.mockRejectedValue(rateLimitError);

    await expect(apiClient.get('/data')).rejects.toMatchObject({
      status: 429
    });
  });
});
            
          

Database Operations

            
describe('Database Operations', () => {
  test('creates user record', async () => {
    const userData = { name: 'John', email: 'john@example.com' };
    const user = await db.users.create(userData);

    expect(user).toMatchObject({
      id: expect.toBeString(),
      name: 'John',
      email: 'john@example.com',
      createdAt: expect.toBeDate()
    });
  });

  test('finds user by email', async () => {
    await db.users.create({ name: 'John', email: 'john@example.com' });
    
    const user = await db.users.findByEmail('john@example.com');
    expect(user).toMatchObject({ name: 'John' });
  });

  test('handles duplicate email error', async () => {
    await db.users.create({ name: 'John', email: 'john@example.com' });

    await expect(
      db.users.create({ name: 'Jane', email: 'john@example.com' })
    ).rejects.toThrow(/duplicate.*email/i);
  });

  test('transaction rollback on error', async () => {
    await expect(async () => {
      await db.transaction(async (tx) => {
        await tx.users.create({ name: 'John' });
        throw new Error('Simulated error');
      });
    }).rejects.toThrow('Simulated error');

    // Verify no user was created
    const users = await db.users.findAll();
    expect(users).toHaveLength(0);
  });
});
            
          

File System Operations

            
describe('File Operations', () => {
  test('reads file content', async () => {
    const content = await fs.readFile('test-file.txt', 'utf8');
    
    expect(content).toBeString();
    expect(content).toMatch(/test content/);
  });

  test('writes file successfully', async () => {
    const testContent = 'Hello, World!';
    
    await expect(
      fs.writeFile('output.txt', testContent)
    ).toResolve();

    const writtenContent = await fs.readFile('output.txt', 'utf8');
    expect(writtenContent).toBe(testContent);
  });

  test('handles file not found error', async () => {
    await expect(
      fs.readFile('non-existent-file.txt')
    ).rejects.toThrow(/ENOENT/);
  });
});
            
          

Testing Time-Dependent Code

Fauji includes fake timers powered by @sinonjs/fake-timers for testing time-dependent functionality.

Basic Timer Control

            
describe('Timer Testing', () => {
  beforeEach(() => {
    // Enable fake timers
    useFakeTimers();
  });

  afterEach(() => {
    // Restore real timers
    useRealTimers();
  });

  test('delays execution with setTimeout', async () => {
    let executed = false;
    
    setTimeout(() => {
      executed = true;
    }, 1000);

    expect(executed).toBe(false);
    
    // Advance time by 1000ms
    advanceTimersByTime(1000);
    
    expect(executed).toBe(true);
  });

  test('intervals execute repeatedly', () => {
    let count = 0;
    
    const intervalId = setInterval(() => {
      count++;
    }, 100);

    // Advance by 350ms (should execute 3 times)
    advanceTimersByTime(350);
    expect(count).toBe(3);

    clearInterval(intervalId);
  });

  test('promise with timeout', async () => {
    const delayedPromise = new Promise(resolve => {
      setTimeout(() => resolve('completed'), 500);
    });

    // Start the promise
    const resultPromise = delayedPromise;
    
    // Advance time to complete the timeout
    advanceTimersByTime(500);
    
    await expect(resultPromise).resolves.toBe('completed');
  });
});
            
          

Advanced Timer Scenarios

            
describe('Advanced Timer Testing', () => {
  beforeEach(() => {
    useFakeTimers();
  });

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

  test('debounce function', () => {
    let callCount = 0;
    const debouncedFn = debounce(() => callCount++, 250);

    // Call function multiple times rapidly
    debouncedFn();
    debouncedFn();
    debouncedFn();

    expect(callCount).toBe(0); // Not called yet

    // Advance time by less than debounce delay
    advanceTimersByTime(200);
    expect(callCount).toBe(0); // Still not called

    // Advance past debounce delay
    advanceTimersByTime(100);
    expect(callCount).toBe(1); // Called once
  });

  test('throttle function', () => {
    let callCount = 0;
    const throttledFn = throttle(() => callCount++, 100);

    throttledFn(); // Should execute immediately
    expect(callCount).toBe(1);

    throttledFn(); // Should be throttled
    throttledFn(); // Should be throttled
    expect(callCount).toBe(1);

    advanceTimersByTime(100);
    throttledFn(); // Should execute after throttle period
    expect(callCount).toBe(2);
  });

  test('retry with exponential backoff', async () => {
    let attempts = 0;
    const failingFunction = async () => {
      attempts++;
      if (attempts < 3) {
        throw new Error('Network error');
      }
      return 'success';
    };

    const retryPromise = retryWithBackoff(failingFunction, {
      maxAttempts: 3,
      baseDelay: 100
    });

    // Advance through retry attempts
    advanceTimersByTime(100); // First retry
    advanceTimersByTime(200); // Second retry (exponential backoff)
    
    await expect(retryPromise).resolves.toBe('success');
    expect(attempts).toBe(3);
  });
});
            
          

Error Handling Patterns

Testing Error Boundaries

            
describe('Error Handling', () => {
  test('catches and handles async errors', async () => {
    const errorHandler = fn();
    
    const riskyOperation = async () => {
      try {
        await Promise.reject(new Error('Something went wrong'));
      } catch (error) {
        errorHandler(error);
        return { error: error.message };
      }
    };

    const result = await riskyOperation();
    
    expect(errorHandler).toHaveBeenCalledWith(
      expect.objectContaining({ message: 'Something went wrong' })
    );
    expect(result).toMatchObject({
      error: 'Something went wrong'
    });
  });

  test('timeout handling', async () => {
    const slowOperation = () => new Promise(resolve => {
      setTimeout(() => resolve('completed'), 5000);
    });

    const timeoutPromise = Promise.race([
      slowOperation(),
      new Promise((_, reject) => 
        setTimeout(() => reject(new Error('Timeout')), 1000)
      )
    ]);

    useFakeTimers();
    
    // Advance to trigger timeout
    advanceTimersByTime(1000);
    
    await expect(timeoutPromise).rejects.toThrow('Timeout');
    
    useRealTimers();
  });
});
            
          

Testing Parallel Operations

            
describe('Parallel Operations', () => {
  test('Promise.all success', async () => {
    const operations = [
      fetchUser(1),
      fetchUser(2),
      fetchUser(3)
    ];

    const results = await Promise.all(operations);
    
    expect(results).toHaveLength(3);
    expect(results[0]).toMatchObject({ id: 1 });
    expect(results[1]).toMatchObject({ id: 2 });
    expect(results[2]).toMatchObject({ id: 3 });
  });

  test('Promise.all failure', async () => {
    const operations = [
      Promise.resolve('success1'),
      Promise.reject(new Error('failure')),
      Promise.resolve('success2')
    ];

    await expect(Promise.all(operations)).rejects.toThrow('failure');
  });

  test('Promise.allSettled results', async () => {
    const operations = [
      Promise.resolve('success'),
      Promise.reject(new Error('failure')),
      Promise.resolve('another success')
    ];

    const results = await Promise.allSettled(operations);
    
    expect(results).toHaveLength(3);
    expect(results[0]).toMatchObject({ 
      status: 'fulfilled', 
      value: 'success' 
    });
    expect(results[1]).toMatchObject({ 
      status: 'rejected',
      reason: expect.objectContaining({ message: 'failure' })
    });
    expect(results[2]).toMatchObject({ 
      status: 'fulfilled', 
      value: 'another success' 
    });
  });

  test('concurrent with limit', async () => {
    const tasks = Array.from({ length: 10 }, (_, i) => 
      () => Promise.resolve(`result-${i}`)
    );

    const results = await runConcurrent(tasks, { concurrency: 3 });
    
    expect(results).toHaveLength(10);
    expect(results).toContain('result-0');
    expect(results).toContain('result-9');
  });
});
            
          

Best Practices

Always Handle Promises

Critical: Always await promises or return them from test functions. Unhandled promises can cause flaky tests.
            
// ❌ Wrong - Promise not awaited
test('bad async test', () => {
  expect(asyncFunction()).resolves.toBe('value'); // Missing await!
});

// ✅ Correct - Promise properly awaited
test('good async test', async () => {
  await expect(asyncFunction()).resolves.toBe('value');
});

// ✅ Also correct - Promise returned
test('good promise test', () => {
  return expect(asyncFunction()).resolves.toBe('value');
});
            
          

Use Specific Async Matchers

            
// ❌ Less clear
test('promise resolves', async () => {
  const result = await somePromise();
  expect(result).toBe('expected');
});

// ✅ More expressive
test('promise resolves', async () => {
  await expect(somePromise()).resolves.toBe('expected');
});
            
          

Test Both Success and Failure Cases

            
describe('API Client', () => {
  test('successful request', async () => {
    await expect(api.getUser(1)).resolves.toMatchObject({
      id: 1,
      name: expect.toBeString()
    });
  });

  test('handles 404 error', async () => {
    await expect(api.getUser(999)).rejects.toThrow('User not found');
  });

  test('handles network error', async () => {
    mockFetch.mockRejectedValue(new Error('Network error'));
    await expect(api.getUser(1)).rejects.toThrow('Network error');
  });
});
            
          

Control Time in Tests

            
// ❌ Real timers make tests slow and flaky
test('waits for real time', async () => {
  setTimeout(() => {
    // This test will actually wait 1 second
  }, 1000);
  await new Promise(resolve => setTimeout(resolve, 1000));
});

// ✅ Fake timers are fast and reliable
test('controls time', () => {
  useFakeTimers();
  let executed = false;
  
  setTimeout(() => { executed = true; }, 1000);
  advanceTimersByTime(1000);
  
  expect(executed).toBe(true);
  useRealTimers();
});
            
          

Common Pitfalls

  • Forgetting to await: Always await async operations in tests
  • Floating promises: Unhandled promises can cause race conditions
  • Real timers in tests: Use fake timers for time-dependent code
  • Not testing error cases: Always test both success and failure scenarios
  • Overly complex async logic: Break down complex async flows into smaller, testable units

Integration with Other Features

Async testing works seamlessly with other Fauji features:

  • Mock Functions: Mock async functions and verify their calls
  • Setup/Teardown: Use async setup and teardown functions
  • Custom Matchers: Create async custom matchers
  • Error Matchers: Combine async testing with error assertions

For more information, see: