19API Chaining
What you will master here
- What "chaining" means and when interviewers ask about it
- Sequential (linear) chains: A → B → C, passing ids forward
- DAG / tree chains: one parent forks into parallel children
- State holders: in-test variables vs fixtures vs context objects
- Cleanup ordering (LIFO — reverse of creation)
- Negative chains: failure in step N skips N+1
- Common pitfalls and idempotency keys
19.1 What is API chaining?
Most real backend flows are a sequence of dependent calls: create a resource, take its id, use it in the next call. Each step depends on data produced by an earlier step. An "API chain test" is a single test that exercises that sequence end-to-end at the API layer (no UI).
Example chain:
- POST /users → returns
{ id, email } - POST /users/{id}/cart/items → returns
{ cartId, items } - POST /orders with
{ cartId }→ returns{ orderId, status: 'pending' } - POST /orders/{orderId}/pay → returns
{ status: 'paid' } - GET /orders/{orderId} → final assertion: status is paid, total correct
19.2 Sequential (linear) chain
test('full checkout chain', async ({ request }) => {
// 1. Create user
const user = await (await request.post('/api/users', {
data: { email: `qa+${Date.now()}@test.com`, password: 'p4ssw0rd' },
})).json();
expect(user.id).toBeTruthy();
// 2. Login → token (depends on user.email)
const login = await (await request.post('/api/auth/login', {
data: { email: user.email, password: 'p4ssw0rd' },
})).json();
const auth = { Authorization: `Bearer ${login.token}` };
// 3. Add item to cart (depends on user + token)
const cart = await (await request.post(`/api/users/${user.id}/cart/items`, {
headers: auth,
data: { sku: 'SHOE-42', qty: 1 },
})).json();
expect(cart.items).toHaveLength(1);
// 4. Create order (depends on cart.id)
const order = await (await request.post('/api/orders', {
headers: auth, data: { cartId: cart.id },
})).json();
expect(order.status).toBe('pending');
// 5. Pay (depends on order.id)
await request.post(`/api/orders/${order.id}/pay`, {
headers: auth, data: { method: 'card', token: 'tok_test' },
});
// 6. Verify final state
const final = await (await request.get(`/api/orders/${order.id}`, { headers: auth })).json();
expect(final.status).toBe('paid');
expect(final.total).toBe(99.00);
});
19.3 DAG / tree chain — one parent, many children
Sometimes the chain isn't linear. A single creation step produces an id that's used by several independent follow-ups. Run them in parallel for speed.
test('user onboarding fans out then joins', async ({ request }) => {
// 1. Parent step
const user = await (await request.post('/api/users', {
data: { email: `qa+${Date.now()}@test.com` },
})).json();
const headers = { Authorization: `Bearer ${user.token}` };
// 2. Parallel children — independent of each other, all depend on user.id
const [addr, card, cart] = await Promise.all([
request.post(`/api/users/${user.id}/addresses`, { headers, data: { line1: '1 Test St', city: 'BLR' } }),
request.post(`/api/users/${user.id}/cards`, { headers, data: { token: 'tok_test_visa' } }),
request.post(`/api/users/${user.id}/cart`, { headers, data: { items: [{ sku: 'A', qty: 2 }] } }),
]);
expect(addr.status()).toBe(201);
expect(card.status()).toBe(201);
expect(cart.status()).toBe(201);
// 3. Join — verify the combined state
const profile = await (await request.get(`/api/users/${user.id}/profile`, { headers })).json();
expect(profile.addresses).toHaveLength(1);
expect(profile.paymentMethods).toHaveLength(1);
expect(profile.cart.items).toHaveLength(1);
});
19.4 Holding state across the chain
(a) In-test variables — simplest
Already shown above. Good for a single-test chain. Variables (user, cart, order) flow top to bottom.
(b) Fixture-based — shared across tests
If the chain produces state needed by multiple tests, lift it into a fixture so the runner builds it once and tears it down once.
export const test = base.extend<{
paidOrder: { orderId: string; userId: string; token: string };
}>({
paidOrder: async ({ request }, use) => {
/* setup chain */
const u = await (await request.post('/api/users', { data: {/* ... */} })).json();
const c = await (await request.post(`/api/users/${u.id}/cart`, { data: {/* ... */} })).json();
const o = await (await request.post('/api/orders', { data: { cartId: c.id } })).json();
await request.post(`/api/orders/${o.id}/pay`, { data: { method: 'card' } });
await use({ orderId: o.id, userId: u.id, token: u.token });
/* teardown — reverse order */
await request.delete(`/api/orders/${o.id}`);
await request.delete(`/api/users/${u.id}`);
},
});
test('refund a paid order', async ({ request, paidOrder }) => {
const r = await request.post(`/api/orders/${paidOrder.orderId}/refund`, {
headers: { Authorization: `Bearer ${paidOrder.token}` },
});
expect(r.status()).toBe(200);
});
(c) Context-object pattern — readable for long chains
For chains with 8+ steps, accumulate state in one object so the flow reads as steps acting on shared state.
type Ctx = { userId?: string; token?: string; cartId?: string; orderId?: string };
async function step1(ctx: Ctx, request: APIRequestContext) {
const u = await (await request.post('/api/users', { data: {/* ... */} })).json();
ctx.userId = u.id; ctx.token = u.token;
}
async function step2(ctx: Ctx, request: APIRequestContext) {
const c = await (await request.post(`/api/users/${ctx.userId}/cart`, {
headers: { Authorization: `Bearer ${ctx.token}` }, data: {/* ... */}
})).json();
ctx.cartId = c.id;
}
/* ...stepN... */
test('long checkout chain', async ({ request }) => {
const ctx: Ctx = {};
await step1(ctx, request);
await step2(ctx, request);
/* ... */
expect(ctx.orderId).toBeTruthy();
});
19.5 Cleanup ordering — LIFO
Created in order A → B → C → D. Delete in order D → C → B → A. Foreign keys often require it; ignoring causes 409 Conflict.
test('chain with proper teardown', async ({ request }) => {
const created: { url: string }[] = [];
try {
const u = await postAndTrack(request, '/api/users', { /* ... */ }, created);
const c = await postAndTrack(request, `/api/users/${u.id}/cart`, { /* ... */ }, created);
const o = await postAndTrack(request, '/api/orders', { cartId: c.id }, created);
expect(o.status).toBe('pending');
} finally {
/* reverse-order delete; ignore individual failures so we attempt all */
for (const { url } of created.reverse()) {
try { await request.delete(url); } catch {}
}
}
});
async function postAndTrack(request: APIRequestContext, url: string, data: any, list: any[]) {
const r = await request.post(url, { data });
const body = await r.json();
list.push({ url: `${url}/${body.id}` });
return body;
}
19.6 Negative chains — fail fast on intermediate failure
If step 2 fails, step 3 has nothing to act on. Assertions on each step make the test stop at the right place with a clear message — instead of confusing cascading failures three steps later.
test('failure surfaces at the right step', async ({ request }) => {
const user = await (await request.post('/api/users', { data: { email: 'qa@test.com' } })).json();
expect(user.id, 'user creation failed').toBeTruthy();
const dup = await request.post('/api/users', { data: { email: 'qa@test.com' } });
expect(dup.status(), 'expected 409 on duplicate email').toBe(409);
/* No further steps — they'd be meaningless */
});
expect(value, 'message').toBe(x) attaches a label to the assertion. Saves debug time on long chains.19.7 Idempotency keys — chain safety
If the chain retries on a transient error and you POST twice, you don't want two orders. Send an Idempotency-Key header; the server returns the same response for retries with the same key.
const key = crypto.randomUUID();
const first = await request.post('/api/orders', { data, headers: { 'Idempotency-Key': key } });
const second = await request.post('/api/orders', { data, headers: { 'Idempotency-Key': key } });
expect(second.status()).toBe(first.status());
expect((await second.json()).id).toBe((await first.json()).id); // same order, not duplicate
19.8 Common pitfalls
- Forgetting to await — POST returns a Response promise; you must await it AND its
.json(). - Reusing data across tests — chain A leaves a half-built record; chain B sees it and behaves differently. Always create fresh, clean up.
- Hardcoded ids — never hardcode
/users/42. Always derive from the previous step. - No timeout on the whole chain — set a per-test timeout (
test.setTimeout(60_000)) for long chains to avoid silent stalls. - Missing intermediate assertions — if every step succeeds quietly and only the last assertion runs, a failure in step 3 leaves you bisecting. Assert each step's success.
19.9 Chain vs single test — what to pick
| Concern | Single test with chain | Many small tests + fixtures |
|---|---|---|
| Speed | One test, one setup | Fixture builds once per worker, shared |
| Failure clarity | Long step list, may bisect | Each step is its own test, names the failing piece |
| Reuse of state | Recreated every test | Cached in fixture, reused |
| Best for | End-to-end happy path (one test, full journey) | Variations on the same setup (refund, cancel, modify all need a paid order) |
Module 12 — Interview Q&A bank
What does "API chaining" mean?
Is API chaining sequential or tree-structured?
Promise.all for the parallel branch to save wall-clock time.How do you pass data between steps?
Why is teardown order important?
What should happen when step 3 of a 6-step chain fails?
expect after each step, with a meaningful message via the second arg to expect.What's an idempotency key and why does it matter in a chain?
Should every chain step be its own test?
How do you parallelise a chain?
await Promise.all([...]). Across tests: each test runs in its own context — Playwright already parallelises them across workers.How do you handle a chain that includes a webhook callback?
expect.poll for up to a few seconds until the webhook has been processed. Alternatively, expose a test-only endpoint that synchronously processes the webhook to remove the async wait.Where do you draw the line between API chain test and E2E UI test?
What's wrong with hardcoded ids like /users/42 in a chain?
How do you debug a long chain that fails intermittently?
expect.poll instead of fixed waits.