Use Cypress-style polling with Playwright's expect.poll to wait for asynchronous conditions. Provides configurable timeout, interval, logging, and post-polling callbacks with enhanced error categorization. Ideal for backend testing: polling API endpoints for job completion, database eventual consistency, message queue processing, and cache propagation.
Testing async operations (background jobs, eventual consistency, webhook processing) requires polling:
expect.poll is verboseThe recurse utility provides:
import { test } from '@seontechnologies/playwright-utils/recurse/fixtures';
test('wait for job completion', async ({ recurse, apiRequest }) => {
const { body } = await apiRequest({
method: 'POST',
path: '/api/jobs',
body: { type: 'export' },
});
// Poll until job completes
const result = await recurse(
() => apiRequest({ method: 'GET', path: `/api/jobs/${body.id}` }),
(response) => response.body.status === 'completed',
{ timeout: 60000 }
);
expect(result.body.downloadUrl).toBeDefined();
});
Context: Wait for async operation to complete with custom timeout and interval.
Implementation:
import { test } from '@seontechnologies/playwright-utils/recurse/fixtures';
test('should wait for job completion', async ({ recurse, apiRequest }) => {
// Start job
const { body } = await apiRequest({
method: 'POST',
path: '/api/jobs',
body: { type: 'export' },
});
// Poll until ready
const result = await recurse(
() => apiRequest({ method: 'GET', path: `/api/jobs/${body.id}` }),
(response) => response.body.status === 'completed',
{
timeout: 60000, // 60 seconds max
interval: 2000, // Check every 2 seconds
log: 'Waiting for export job to complete',
}
);
expect(result.body.downloadUrl).toBeDefined();
});
Key Points:
Context: Use assertions directly in predicate for more expressive tests.
Implementation:
test('should poll with assertions', async ({ recurse, apiRequest }) => {
await apiRequest({
method: 'POST',
path: '/api/events',
body: { type: 'user-created', userId: '123' },
});
// Poll with assertions in predicate - no return true needed!
await recurse(
async () => {
const { body } = await apiRequest({ method: 'GET', path: '/api/events/123' });
return body;
},
(event) => {
// If all assertions pass, predicate succeeds
expect(event.processed).toBe(true);
expect(event.timestamp).toBeDefined();
// No need to return true - just let assertions pass
},
{ timeout: 30000 }
);
});
Why no return true needed?
The predicate checks for "truthiness" of the return value. But there's a catch - in JavaScript, an empty return (or no return) returns undefined, which is falsy!
The utility handles this by checking if:
undefined (implicit return) or truthySo you can:
// Option 1: Use assertions only (recommended)
(event) => {
expect(event.processed).toBe(true);
};
// Option 2: Return boolean (also works)
(event) => event.processed === true;
// Option 3: Mixed (assertions + explicit return)
(event) => {
expect(event.processed).toBe(true);
return true;
};
Context: Understanding the different error types.
Error Types:
// RecurseTimeoutError - Predicate never returned true within timeout
// Contains last command value and predicate error
try {
await recurse(/* ... */);
} catch (error) {
if (error instanceof RecurseTimeoutError) {
console.log('Timed out. Last value:', error.lastCommandValue);
console.log('Last predicate error:', error.lastPredicateError);
}
}
// RecurseCommandError - Command function threw an error
// The command itself failed (e.g., network error, API error)
// RecursePredicateError - Predicate function threw (not from assertions failing)
// Logic error in your predicate code
Custom Error Messages:
test('custom error on timeout', async ({ recurse, apiRequest }) => {
try {
await recurse(
() => apiRequest({ method: 'GET', path: '/api/status' }),
(res) => res.body.ready === true,
{
timeout: 10000,
error: 'System failed to become ready within 10 seconds - check background workers',
}
);
} catch (error) {
// Error message includes custom context
expect(error.message).toContain('check background workers');
throw error;
}
});
Context: Process or log results after successful polling.
Implementation:
test('post-poll processing', async ({ recurse, apiRequest }) => {
const finalResult = await recurse(
() => apiRequest({ method: 'GET', path: '/api/batch-job/123' }),
(res) => res.body.status === 'completed',
{
timeout: 60000,
post: (result) => {
// Runs after successful polling
console.log(`Job completed in ${result.body.duration}ms`);
console.log(`Processed ${result.body.itemsProcessed} items`);
return result.body;
},
}
);
expect(finalResult.itemsProcessed).toBeGreaterThan(0);
});
Key Points:
post callback runs after predicate succeedsrecurse resultContext: Wait for UI elements to reach a specific state through polling.
Implementation:
test('table data loads', async ({ page, recurse }) => {
await page.goto('/reports');
// Poll for table rows to appear
await recurse(
async () => page.locator('table tbody tr').count(),
(count) => count >= 10, // Wait for at least 10 rows
{
timeout: 15000,
interval: 500,
log: 'Waiting for table data to load',
}
);
// Now safe to interact with table
await page.locator('table tbody tr').first().click();
});
Context: Testing eventual consistency with message queue processing.
Implementation:
test('kafka event processed', async ({ recurse, apiRequest }) => {
// Trigger action that publishes Kafka event
await apiRequest({
method: 'POST',
path: '/api/orders',
body: { productId: 'ABC123', quantity: 2 },
});
// Poll for downstream effect of Kafka consumer processing
const inventoryResult = await recurse(
() => apiRequest({ method: 'GET', path: '/api/inventory/ABC123' }),
(res) => {
// Assumes test fixture seeds inventory at 100; in production tests,
// fetch baseline first and assert: expect(res.body.available).toBe(baseline - 2)
expect(res.body.available).toBeLessThanOrEqual(98);
},
{
timeout: 30000, // Kafka processing may take time
interval: 1000,
log: 'Waiting for Kafka event to be processed',
}
);
expect(inventoryResult.body.lastOrderId).toBeDefined();
});
Context: Most common use case - polling API endpoints for state changes.
Implementation:
import { test } from '@seontechnologies/playwright-utils/fixtures';
test('end-to-end polling', async ({ apiRequest, recurse }) => {
// Trigger async operation
const { body: createResp } = await apiRequest({
method: 'POST',
path: '/api/data-import',
body: { source: 's3://bucket/data.csv' },
});
// Poll until import completes
const importResult = await recurse(
() => apiRequest({ method: 'GET', path: `/api/data-import/${createResp.importId}` }),
(response) => {
const { status, rowsImported } = response.body;
return status === 'completed' && rowsImported > 0;
},
{
timeout: 120000, // 2 minutes for large imports
interval: 5000, // Check every 5 seconds
log: `Polling import ${createResp.importId}`,
}
);
expect(importResult.body.rowsImported).toBeGreaterThan(1000);
expect(importResult.body.errors).toHaveLength(0);
});
Key Points:
apiRequest + recurse for API polling@seontechnologies/playwright-utils/fixtures| Option | Type | Default | Description |
|---|---|---|---|
timeout |
number |
30000 |
Maximum time to wait (ms) |
interval |
number |
1000 |
Time between polls (ms) |
log |
string |
undefined |
Message logged on each poll |
error |
string |
undefined |
Custom error message for timeout |
post |
(result: T) => R |
undefined |
Callback after successful poll |
delay |
number |
0 |
Initial delay before first poll (ms) |
| Error Type | When Thrown | Properties |
|---|---|---|
RecurseTimeoutError |
Predicate never passed within timeout | lastCommandValue, lastPredicateError |
RecurseCommandError |
Command function threw an error | cause (original error) |
RecursePredicateError |
Predicate threw (not assertion failure) | cause (original error) |
| Vanilla Playwright | recurse Utility |
|---|---|
await expect.poll(() => { ... }, { timeout: 30000 }).toBe(true) |
await recurse(() => { ... }, (val) => val === true, { timeout: 30000 }) |
| No logging | Built-in log option |
| Generic timeout errors | Categorized errors (timeout/command/predicate) |
| No post-poll hooks | post callback support |
Use recurse for:
Stick with vanilla expect.poll for:
expect(locator).toBeVisible())api-testing-patterns.md - Comprehensive pure API testing patternsapi-request.md - Combine for API endpoint pollingoverview.md - Fixture composition patternsfixtures-composition.md - Using with mergeTestscontract-testing.md - Contract testing with async verificationDON'T use hard waits instead of polling:
await page.click('#export');
await page.waitForTimeout(5000); // Arbitrary wait
expect(await page.textContent('#status')).toBe('Ready');
DO poll for actual condition:
await page.click('#export');
await recurse(
() => page.textContent('#status'),
(status) => status === 'Ready',
{ timeout: 10000 }
);
DON'T poll too frequently:
await recurse(
() => apiRequest({ method: 'GET', path: '/status' }),
(res) => res.body.ready,
{ interval: 100 } // Hammers API every 100ms!
);
DO use reasonable interval for API calls:
await recurse(
() => apiRequest({ method: 'GET', path: '/status' }),
(res) => res.body.ready,
{ interval: 2000 } // Check every 2 seconds (reasonable)
);