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:
- Mock Functions Guide for async mocking patterns
- Setup & Teardown Guide for async lifecycle hooks