Async/Await Patterns
Modern test frameworks rely heavily on asynchronous execution. Browser automation (Playwright, Puppeteer), concurrent API testing, and event-driven architectures all require understanding async/await. Failing to understand asynchronous patterns is the most common source of flaky tests and mysterious hangs in modern test suites.
Why Async Matters for QA
When your test clicks a button, the browser does not instantly update. When your test sends an API request, the response does not arrive instantly. Asynchronous programming provides a way to write code that waits for these operations without blocking the entire test run.
| Synchronous | Asynchronous |
|---|---|
| Each operation blocks until complete | Operations can run concurrently |
| Simple to reason about | Requires understanding of event loop |
| Slow for I/O-heavy tasks | Fast for parallel I/O operations |
requests.get() |
await httpx.get() |
TypeScript/JavaScript Async Patterns
Basic Async/Await
// Every Playwright interaction is async
test('user can add item to cart', async ({ page }) => {
await page.goto('/products');
await page.click('[data-testid="add-to-cart"]');
await expect(page.locator('.cart-count')).toHaveText('1');
});
The await keyword pauses execution until the operation completes. Without it, the code continues immediately — often before the operation finishes.
The Forgotten Await Bug
// BUG: missing await — test passes incorrectly
test('should have correct title', async ({ page }) => {
await page.goto('/dashboard');
// This returns a Promise, which is truthy — so the assert passes regardless!
expect(page.title()).toBe('Dashboard');
// FIX: await the title() call
expect(await page.title()).toBe('Dashboard');
});
This is the most common async bug in test automation. The test asserts against a Promise object (which is truthy), not the actual value. The test passes even when the title is wrong.
Concurrent API Requests
// Sequential: each request waits for the previous one (slow)
const user = await fetch('/api/users/1');
const orders = await fetch('/api/orders?user=1');
const payments = await fetch('/api/payments?user=1');
// Total time: user + orders + payments
// Concurrent: all requests run in parallel (fast)
const [user, orders, payments] = await Promise.all([
fetch('/api/users/1'),
fetch('/api/orders?user=1'),
fetch('/api/payments?user=1'),
]);
// Total time: max(user, orders, payments)
Promise.allSettled for Resilient Tests
// Promise.all fails fast — if one request fails, all fail
// Promise.allSettled waits for all to complete, regardless of success/failure
const results = await Promise.allSettled([
fetch('/api/endpoint-a'),
fetch('/api/endpoint-b'),
fetch('/api/endpoint-c'),
]);
const failures = results.filter(r => r.status === 'rejected');
const successes = results.filter(r => r.status === 'fulfilled');
expect(failures).toHaveLength(0);
Python Async Patterns
asyncio Basics
import asyncio
import httpx
async def test_concurrent_requests():
async with httpx.AsyncClient() as client:
tasks = [client.get(f"https://api.example.com/items/{i}") for i in range(100)]
responses = await asyncio.gather(*tasks)
assert all(r.status_code == 200 for r in responses)
pytest-asyncio
import pytest
import httpx
@pytest.mark.asyncio
async def test_api_health(base_url):
async with httpx.AsyncClient() as client:
response = await client.get(f"{base_url}/health")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_parallel_endpoint_availability(base_url):
endpoints = ["/users", "/products", "/orders", "/health"]
async with httpx.AsyncClient() as client:
tasks = [client.get(f"{base_url}{ep}") for ep in endpoints]
responses = await asyncio.gather(*tasks)
for endpoint, response in zip(endpoints, responses):
assert response.status_code == 200, f"{endpoint} returned {response.status_code}"
asyncio.wait_for with Timeout
async def test_slow_endpoint_timeout():
async with httpx.AsyncClient() as client:
try:
response = await asyncio.wait_for(
client.get("https://api.example.com/slow-endpoint"),
timeout=5.0
)
assert response.status_code == 200
except asyncio.TimeoutError:
pytest.fail("Endpoint did not respond within 5 seconds")
Common Async Pitfalls
1. Forgotten Await
Already covered above — the most dangerous bug because the test passes silently.
Detection: Use TypeScript strict mode and ESLint rules like @typescript-eslint/no-floating-promises.
2. Race Conditions in Tests
// BUG: click may happen before navigation completes
await page.goto('/products');
page.click('[data-testid="first-product"]'); // missing await!
await expect(page).toHaveURL(/\/products\/\d+/); // may fail intermittently
// FIX: await the click
await page.goto('/products');
await page.click('[data-testid="first-product"]');
await expect(page).toHaveURL(/\/products\/\d+/);
3. Shared State Between Concurrent Tests
# BUG: parallel tests modify the same user
async def test_update_name():
await api.patch("/users/1", json={"name": "Alice"})
user = await api.get("/users/1")
assert user["name"] == "Alice" # May fail if test_update_email runs concurrently
async def test_update_email():
await api.patch("/users/1", json={"email": "new@test.com"})
user = await api.get("/users/1")
assert user["email"] == "new@test.com"
# FIX: each test creates its own user
async def test_update_name():
user = await api.post("/users", json={"name": "Original"})
await api.patch(f"/users/{user['id']}", json={"name": "Alice"})
updated = await api.get(f"/users/{user['id']}")
assert updated["name"] == "Alice"
4. Unhandled Promise Rejections
// BUG: if the fetch fails, the error is swallowed
fetch('/api/cleanup').then(r => r.json()); // no await, no catch
// FIX: always await or catch
try {
await fetch('/api/cleanup');
} catch (e) {
console.warn('Cleanup failed:', e);
}
5. Event Loop Blocking
# BUG: synchronous sleep blocks the event loop
async def test_with_delay():
import time
time.sleep(5) # Blocks the entire event loop for 5 seconds!
# FIX: use async sleep
async def test_with_delay():
await asyncio.sleep(5) # Non-blocking
Async in Test Frameworks
| Framework | Async Support | Notes |
|---|---|---|
| Playwright | Native async | Every interaction returns a Promise |
| Cypress | Abstracted | Commands are auto-chained; no explicit await |
| pytest | Via pytest-asyncio | Requires @pytest.mark.asyncio decorator |
| Jest | Native | Test functions can be async |
| Selenium | Sync by default | Python bindings are synchronous; async wrapper available |
Practical Exercise
- Write a test that sends 50 concurrent API requests using
Promise.all/asyncio.gatherand verifies all return 200 - Intentionally create the "forgotten await" bug and observe how the test passes incorrectly. Then fix it.
- Write a test with a timeout: if an endpoint does not respond within 3 seconds, the test fails
- Create two tests that would conflict if run concurrently (shared state), then refactor them to be safe for parallel execution
Key Takeaways
- Every async operation must be awaited — forgotten await is the most common async bug
- Use
Promise.all/asyncio.gatherfor concurrent operations - Use
Promise.allSettledwhen you need all results regardless of individual failures - Avoid shared state between concurrent tests — each test should create its own data
- Never block the event loop with synchronous operations in async code
- Enable linting rules to catch missing await at compile time