API Chaining

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:

  1. POST /users → returns { id, email }
  2. POST /users/{id}/cart/items → returns { cartId, items }
  3. POST /orders with { cartId } → returns { orderId, status: 'pending' }
  4. POST /orders/{orderId}/pay → returns { status: 'paid' }
  5. GET /orders/{orderId} → final assertion: status is paid, total correct

19.2 Sequential (linear) chain

POST /users → userId POST /cart uses userId → cartId POST /orders uses cartId → orderId POST /pay uses orderId GET /orders verify state
Figure 9 — Linear chain: each step takes a value the previous step produced.
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.

POST /users → userId POST /users/:id/addresses POST /users/:id/cards POST /users/:id/cart GET /users/:id/profile JOIN — verify combined state
Figure 10 — DAG chain: parent creates id, children run in parallel, all join for the final assertion.
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);
});
Why parallelise childrenIf three calls each take 200 ms, serial takes 600 ms; parallel takes 200 ms. For a suite of 50 tests that's a ~20-second win per run.

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 */
});
Custom message in expectexpect(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

19.9 Chain vs single test — what to pick

ConcernSingle test with chainMany small tests + fixtures
SpeedOne test, one setupFixture builds once per worker, shared
Failure clarityLong step list, may bisectEach step is its own test, names the failing piece
Reuse of stateRecreated every testCached in fixture, reused
Best forEnd-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?
Running a sequence of HTTP calls where each call uses data produced by the previous one — typically ids returned from create endpoints. The whole sequence is exercised as one test (or as a fixture) to validate the end-to-end backend flow.
Is API chaining sequential or tree-structured?
Both, depending on the flow. Sequential (linear) when each step depends on the immediate previous one (login → fetch → update). Tree/DAG when one step produces an id that several independent follow-ups use (create user → in parallel: add address, add card, build cart → join: verify profile). Use Promise.all for the parallel branch to save wall-clock time.
How do you pass data between steps?
Three patterns. (1) Local variables in one test — simplest. (2) A fixture that runs the chain in setup and exposes the resulting state — best when multiple tests share the state. (3) A typed context object accumulated step by step — readable for very long chains.
Why is teardown order important?
Resources have foreign-key dependencies. Deleting a user before deleting its orders typically fails with 409 Conflict. Always teardown in LIFO order — reverse of creation — so child resources are removed before their parents.
What should happen when step 3 of a 6-step chain fails?
The test should fail at step 3 with a clear message identifying that step — not silently let later steps run on bad data and fail confusingly. Add an explicit 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?
A unique value (often UUID) sent in a header on POST. The server caches the response under that key; retries with the same key return the cached response instead of creating a duplicate resource. Lets your test safely retry a transient failure mid-chain.
Should every chain step be its own test?
No — that fights the test runner's isolation model. Keep the chain in one test (or one fixture). If you want different variations of the final state validated, build the state in a shared fixture and write small tests against it.
How do you parallelise a chain?
Inside the chain: wherever multiple steps are independent (all depend on the same parent id but not on each other), use 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?
Trigger the action, then poll the resulting state with 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?
API chains validate backend correctness and contract end-to-end. E2E UI tests validate user journeys including rendering, navigation, browser-specific behaviour. Use both — keep one happy-path E2E for the user journey, lots of API chains for backend permutations.
What's wrong with hardcoded ids like /users/42 in a chain?
The id may not exist in fresh environments (CI), or may be owned by a different test. Always derive ids from the previous step's response. If you need stable seed data, create it via the API at the start of the chain.
How do you debug a long chain that fails intermittently?
Log each step's request and response (URL, status, key body fields). Add intermediate assertions with messages. Capture timings — slow upstream services can race the next call. If a webhook is involved, switch to expect.poll instead of fixed waits.
Can a chain be expressed declaratively (data-driven)?
Yes — a JSON file listing steps (method, url template, body template, capture rules for ids). A small runner walks the list, substituting captured ids into templates. Useful when QA non-engineers maintain chains. Trade-off: harder to debug than plain TypeScript.
How does Postman's "tests + variables" compare to a Playwright chain?
Same idea — Postman collections chain requests and capture values into variables. Playwright gives you full TS, real assertions, the same fixtures and parallelism as your UI tests, and reuses your team's code-review/CI pipeline. Postman wins for ad-hoc exploration and non-engineer collaboration.
If two chains can collide on shared data, how do you isolate them?
Per-test seeding — create a fresh user/account/tenant at the start of each chain, never share. If you must share (slow seed), use a worker-scoped fixture so each worker has its own copy.