Free Ebook cover Postman for API Testing: Collections, Environments, and Automated Checks

Postman for API Testing: Collections, Environments, and Automated Checks

New course

10 pages

Chaining Requests and Workflows: End-to-End API Scenarios in Collections

Capítulo 8

Estimated reading time: 11 minutes

+ Exercise

Designing Workflows in Collections (Setup → Action → Verify → Teardown)

Chaining requests means modeling a real user or system journey where each step depends on data produced by the previous step (IDs, tokens, URLs, timestamps). In Postman, you implement this by running a collection (or folder) in sequence and passing data forward using variables. A reliable workflow is intentionally structured so that each request has a clear role and failures stop the chain early.

Workflow blueprint

  • Setup: Prepare prerequisites (create test resource, seed dependencies, ensure clean state).
  • Action: Perform the operation under test (update, submit, transition state).
  • Verify: Fetch and validate the system state (GET and compare fields, status, invariants).
  • Teardown: Remove created data and reset variables so the environment remains reusable.

In a collection, represent each phase as a folder or as a clearly named request sequence. Keep the chain explicit: request order should match the workflow order, and each request should document which variables it consumes and produces.

Example request sequence (end-to-end)

  • 01 - Setup - Create Widget
  • 02 - Action - Update Widget
  • 03 - Verify - Get Widget and Validate
  • 04 - Teardown - Delete Widget

When you run the folder in Collection Runner (or Newman), Postman executes requests in order. Your scripts should store outputs (like widgetId) and use them in later requests.

Extracting Data from Responses and Persisting It via Variables

Chaining depends on extracting values from responses and persisting them into variables that later requests can reference in URL paths, query parameters, headers, or bodies. The key is to store only what you need, store it in the right scope, and validate it before saving.

What to persist (and where)

  • Resource identifiers (e.g., id, orderNumber): usually collection variables for a run, or environment variables if you need them across runs.
  • Derived values (e.g., computed timestamps, random suffixes): often collection variables.
  • Run metadata (e.g., runId, createdAt): collection variables to keep runs isolated.

Pattern: extract → validate → set

In the Tests tab of the producing request, parse JSON, assert the field exists, then set the variable.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

// Tests (Create Widget): extract widgetId safely
let json;
try {
  json = pm.response.json();
} catch (e) {
  pm.test('Create response is valid JSON', function () {
    pm.expect.fail('Response was not JSON');
  });
}

pm.test('Create returns 201', function () {
  pm.expect(pm.response.code).to.equal(201);
});

pm.test('Create returns an id', function () {
  pm.expect(json).to.have.property('id');
  pm.expect(json.id).to.be.a('string').and.not.empty;
});

if (json && json.id) {
  pm.collectionVariables.set('widgetId', json.id);
}

Then, in subsequent requests, reference {{widgetId}} in the URL or body. Example URL: {{baseUrl}}/widgets/{{widgetId}}.

Persisting multiple values

If you need several fields, store them with clear names and consistent prefixes to avoid collisions.

// Tests: store multiple fields from create
pm.collectionVariables.set('widgetName', json.name);
pm.collectionVariables.set('widgetVersion', String(json.version || '1'));

Guarding Against Flaky Chains (Assert Before Reuse, Fallback Handling)

Chains become flaky when a later request assumes a variable exists, assumes a prior step succeeded, or relies on timing-sensitive behavior (eventual consistency, background processing). To keep workflows stable, treat each handoff as a contract: validate the response and the extracted data before using it.

Assert before reuse

In any request that consumes a variable, add a small guard test that fails fast if the variable is missing. This prevents confusing downstream failures (like 404s or malformed URLs) and makes the root cause obvious.

// Tests (Update Widget): guard that widgetId exists
const widgetId = pm.collectionVariables.get('widgetId');
pm.test('widgetId is available for update', function () {
  pm.expect(widgetId, 'widgetId').to.be.a('string').and.not.empty;
});

Fallback handling (controlled, explicit)

Sometimes you can recover from missing data by re-running a setup step or using a default. Do this only when it makes sense and is deterministic. Prefer failing fast for core identifiers, but you can implement controlled fallbacks for optional fields.

// Example: fallback for optional correlationId
let correlationId = pm.collectionVariables.get('correlationId');
if (!correlationId) {
  correlationId = pm.variables.replaceIn('corr-{{$timestamp}}');
  pm.collectionVariables.set('correlationId', correlationId);
}

Handling eventual consistency in verify steps

If the system updates asynchronously, a verify request might briefly return stale data. Instead of adding arbitrary delays everywhere, isolate the retry logic to the verify request and cap retries to avoid infinite loops.

// Tests (Verify Widget): simple bounded retry using setNextRequest
const maxRetries = 3;
const retryCount = Number(pm.collectionVariables.get('verifyRetryCount') || 0);

let json = {};
try { json = pm.response.json(); } catch (e) {}

const expectedName = pm.collectionVariables.get('expectedWidgetName');
const actualName = json.name;

if (actualName !== expectedName && retryCount < maxRetries) {
  pm.collectionVariables.set('verifyRetryCount', String(retryCount + 1));
  postman.setNextRequest(pm.info.requestName); // rerun this verify request
} else {
  pm.collectionVariables.unset('verifyRetryCount');
  pm.test('Widget name matches expected', function () {
    pm.expect(actualName).to.equal(expectedName);
  });
}

This keeps retries localized and ensures the run still fails clearly if the expected state never appears.

Teardown Patterns to Keep Environments Clean (Delete Created Resources)

Teardown is not optional in chained workflows. If a run creates resources and does not delete them, later runs can fail due to duplicates, quota limits, or polluted datasets. Teardown should run even when earlier steps fail, as much as possible.

Teardown request design

  • Use the stored identifier: DELETE {{baseUrl}}/widgets/{{widgetId}}.
  • Make teardown tolerant: if the resource is already gone, treat 404 as acceptable (idempotent cleanup).
  • Unset variables after deletion to avoid accidental reuse in later runs.
// Tests (Delete Widget): accept 200/204/404 and clean variables
pm.test('Delete returns success or already deleted', function () {
  pm.expect([200, 202, 204, 404]).to.include(pm.response.code);
});

// Clean up variables regardless
pm.collectionVariables.unset('widgetId');
pm.collectionVariables.unset('widgetName');
pm.collectionVariables.unset('expectedWidgetName');

Ensuring teardown runs

In Postman collection runs, requests execute in order. A failing test does not automatically stop execution unless you explicitly control flow. If you want to stop on critical failures but still run teardown, use a pattern where you set a flag and route to teardown.

// Example: in any critical failure scenario
pm.collectionVariables.set('runFailed', 'true');
postman.setNextRequest('04 - Teardown - Delete Widget');

Then in teardown, always attempt cleanup if widgetId exists, and finally unset runFailed so the next run starts clean.

Separating Test Data from Test Logic for Easier Maintenance

Chained workflows become hard to maintain when request scripts contain hard-coded names, payloads, and expected values. Separate what changes often (test data) from what should remain stable (logic that extracts, stores, verifies, and cleans up).

Practical separation strategies

  • Use a single “data object” variable: store a JSON string containing the test inputs and expected outputs for the run.
  • Use consistent variable names: e.g., input.widget.name is not supported as a variable key, but you can store JSON and access properties in scripts.
  • Generate unique names in setup: keep uniqueness logic in one place, then reuse the generated value across action and verify steps.

Example: store test data as JSON in a collection variable

// Pre-request (or Tests in Setup): define run data once
const runData = {
  widget: {
    initialName: `Widget-{{$timestamp}}`,
    updatedName: `Widget-Updated-{{$timestamp}}`,
    color: 'blue'
  }
};

// Replace dynamic tokens before saving
const resolved = JSON.parse(pm.variables.replaceIn(JSON.stringify(runData)));
pm.collectionVariables.set('runData', JSON.stringify(resolved));

Then in requests, read runData and build payloads without duplicating values.

// Pre-request: build request body from runData
const runData = JSON.parse(pm.collectionVariables.get('runData'));

const body = {
  name: runData.widget.initialName,
  color: runData.widget.color
};

pm.variables.set('createWidgetBody', JSON.stringify(body));

Use {{createWidgetBody}} as the raw JSON body in the Create request. For the Update request, build a separate updateWidgetBody from the same runData.

Lab: End-to-End Scenario (Create → Update → Verify → Delete) with Pass/Fail Reporting

This lab models a realistic workflow for a resource called Widget. You will chain four requests in a folder and produce a simple run report that summarizes pass/fail outcomes.

Lab setup: folder and variables

  • Create a folder named Widget E2E inside your collection.
  • Ensure requests use {{baseUrl}} for the host.
  • Create collection variables: widgetId (empty), runData (empty), runSummary (empty), runFailed (empty).

Request 01 - Setup - Create Widget

Request: POST {{baseUrl}}/widgets

Body (raw JSON): {{createWidgetBody}}

Pre-request: define run data and create body.

// Pre-request (Create Widget)
const runData = {
  widget: {
    initialName: `Widget-{{$timestamp}}`,
    updatedName: `Widget-Updated-{{$timestamp}}`,
    color: 'blue'
  }
};
const resolved = JSON.parse(pm.variables.replaceIn(JSON.stringify(runData)));
pm.collectionVariables.set('runData', JSON.stringify(resolved));

const body = { name: resolved.widget.initialName, color: resolved.widget.color };
pm.variables.set('createWidgetBody', JSON.stringify(body));

// Initialize summary
pm.collectionVariables.set('runSummary', JSON.stringify({ passed: 0, failed: 0, checks: [] }));
pm.collectionVariables.unset('runFailed');

Tests: validate response, store widgetId, and record results.

// Tests (Create Widget)
function record(checkName, passed, details) {
  const summary = JSON.parse(pm.collectionVariables.get('runSummary') || '{"passed":0,"failed":0,"checks":[]}');
  summary.checks.push({ name: checkName, passed: !!passed, details: details || '' });
  if (passed) summary.passed += 1; else summary.failed += 1;
  pm.collectionVariables.set('runSummary', JSON.stringify(summary));
  if (!passed) pm.collectionVariables.set('runFailed', 'true');
}

let json;
try { json = pm.response.json(); } catch (e) { json = null; }

const okStatus = pm.response.code === 201;
record('Create: status is 201', okStatus, `status=${pm.response.code}`);

const hasId = json && typeof json.id === 'string' && json.id.length > 0;
record('Create: response has id', hasId, hasId ? `id=${json.id}` : 'missing id');

if (hasId) pm.collectionVariables.set('widgetId', json.id);

// If create failed, jump to teardown
if (!okStatus || !hasId) {
  postman.setNextRequest('04 - Teardown - Delete Widget');
}

Request 02 - Action - Update Widget

Request: PUT {{baseUrl}}/widgets/{{widgetId}}

Body (raw JSON): {{updateWidgetBody}}

Pre-request: guard widgetId, build update body, and store expected values for verification.

// Pre-request (Update Widget)
const widgetId = pm.collectionVariables.get('widgetId');
if (!widgetId) {
  postman.setNextRequest('04 - Teardown - Delete Widget');
}

const runData = JSON.parse(pm.collectionVariables.get('runData'));
const updateBody = { name: runData.widget.updatedName };
pm.variables.set('updateWidgetBody', JSON.stringify(updateBody));

// Store expected values for verify
pm.collectionVariables.set('expectedWidgetName', runData.widget.updatedName);

Tests: validate update response and record pass/fail.

// Tests (Update Widget)
function record(checkName, passed, details) {
  const summary = JSON.parse(pm.collectionVariables.get('runSummary') || '{"passed":0,"failed":0,"checks":[]}');
  summary.checks.push({ name: checkName, passed: !!passed, details: details || '' });
  if (passed) summary.passed += 1; else summary.failed += 1;
  pm.collectionVariables.set('runSummary', JSON.stringify(summary));
  if (!passed) pm.collectionVariables.set('runFailed', 'true');
}

record('Update: status is 200/204', [200, 204].includes(pm.response.code), `status=${pm.response.code}`);

if (![200, 204].includes(pm.response.code)) {
  postman.setNextRequest('04 - Teardown - Delete Widget');
}

Request 03 - Verify - Get Widget and Validate

Request: GET {{baseUrl}}/widgets/{{widgetId}}

Tests: validate fields match expected values; optionally retry if the API is eventually consistent.

// Tests (Verify Widget)
function record(checkName, passed, details) {
  const summary = JSON.parse(pm.collectionVariables.get('runSummary') || '{"passed":0,"failed":0,"checks":[]}');
  summary.checks.push({ name: checkName, passed: !!passed, details: details || '' });
  if (passed) summary.passed += 1; else summary.failed += 1;
  pm.collectionVariables.set('runSummary', JSON.stringify(summary));
  if (!passed) pm.collectionVariables.set('runFailed', 'true');
}

const expectedName = pm.collectionVariables.get('expectedWidgetName');

let json;
try { json = pm.response.json(); } catch (e) { json = null; }

record('Verify: status is 200', pm.response.code === 200, `status=${pm.response.code}`);

const hasJson = !!json;
record('Verify: response is JSON', hasJson, hasJson ? '' : 'not JSON');

if (json) {
  record('Verify: name matches expected', json.name === expectedName, `expected=${expectedName}, actual=${json.name}`);
  // Example of verifying an invariant field
  record('Verify: id matches widgetId', json.id === pm.collectionVariables.get('widgetId'), `actual=${json.id}`);
}

// If verify failed, continue to teardown (do not keep dirty state)
if (pm.collectionVariables.get('runFailed') === 'true') {
  postman.setNextRequest('04 - Teardown - Delete Widget');
}

Request 04 - Teardown - Delete Widget (and print summary)

Request: DELETE {{baseUrl}}/widgets/{{widgetId}}

Tests: accept idempotent outcomes, print a run summary, and clean variables.

// Tests (Delete Widget)
function record(checkName, passed, details) {
  const summary = JSON.parse(pm.collectionVariables.get('runSummary') || '{"passed":0,"failed":0,"checks":[]}');
  summary.checks.push({ name: checkName, passed: !!passed, details: details || '' });
  if (passed) summary.passed += 1; else summary.failed += 1;
  pm.collectionVariables.set('runSummary', JSON.stringify(summary));
  if (!passed) pm.collectionVariables.set('runFailed', 'true');
}

const widgetId = pm.collectionVariables.get('widgetId');
if (!widgetId) {
  record('Teardown: widgetId was not set (nothing to delete)', true, '');
} else {
  record('Teardown: delete returns 200/204/404', [200, 204, 404].includes(pm.response.code), `status=${pm.response.code}`);
}

// Print summary to console for Runner/Newman logs
const summary = JSON.parse(pm.collectionVariables.get('runSummary') || '{"passed":0,"failed":0,"checks":[]}');
console.log('E2E Run Summary:', summary);

// Also expose a compact status variable
pm.collectionVariables.set('runStatus', summary.failed > 0 ? 'FAIL' : 'PASS');

// Clean environment/collection variables
pm.collectionVariables.unset('widgetId');
pm.collectionVariables.unset('expectedWidgetName');
pm.collectionVariables.unset('verifyRetryCount');
pm.collectionVariables.unset('runFailed');

When you run the folder, you get a deterministic chain: create produces widgetId, update uses it, verify checks the final state, and teardown deletes the resource. The runSummary object provides a simple pass/fail report with check-level details, and runStatus can be used by CI steps to interpret outcomes.

Now answer the exercise about the content:

In a chained Postman collection workflow, what is the best way to prevent confusing downstream failures when later requests depend on data like an ID from earlier responses?

You are right! Congratulations, now go to the next page

You missed! Try again.

Stable chaining treats each handoff as a contract: extract values, assert they exist and are valid, then set variables. Consuming requests should guard that required variables (like IDs) are present to fail fast and avoid misleading 404s or malformed URLs.

Next chapter

Running Collections and Interpreting Results: Repeatable Execution and Debugging

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.