QA Engineer Skills 2026QA-2026Async/Await Patterns

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

  1. Write a test that sends 50 concurrent API requests using Promise.all / asyncio.gather and verifies all return 200
  2. Intentionally create the "forgotten await" bug and observe how the test passes incorrectly. Then fix it.
  3. Write a test with a timeout: if an endpoint does not respond within 3 seconds, the test fails
  4. 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.gather for concurrent operations
  • Use Promise.allSettled when 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