Skip to content

Testing APIs

Start with declarative assertions for simple checks. Use tests when the check needs conditions, loops, or comparisons between values. Copy a request into your collection and adapt api.example.com and the response fields to your API. Each recipe states the response shape it expects.

Tests run after captures, post scripts, and assertions whenever a response exists. Inherited tests run collection → outermost folder → nearest folder → request. They can read the final prepared request, response, and RunScope, but cannot mutate state or make HTTP calls. See the matcher reference for supported checks.

Use assertions when an endpoint must return HTTP 200, JSON, and a response in under one second. This example expects a JSON body with status: "ok":

name: Check service health
method: GET
url: https://api.example.com/health
assert:
- expression: status
operator: equals
value: 200
- expression: headers.Content-Type
operator: contains
value: application/json
- expression: body.status
operator: equals
value: ok
- expression: response.time
operator: lt
value: 1000

All four assertions must pass. Choose the timing budget for your environment; network latency contributes to response time. No script is needed for these checks.

Use a schema when several fields must keep their types. This endpoint should return an object such as {"id":7,"email":"reader@example.com","active":true}:

name: Validate a user response
method: GET
url: https://api.example.com/users/7
tests: |-
test("user matches the response contract", () => {
expect(noodle.response.status).toBe(200);
expect(noodle.response.json()).toMatchSchema({
type: "object",
required: ["id", "email", "active"],
properties: {
id: { type: "integer", minimum: 1 },
email: { type: "string", format: "email" },
active: { type: "boolean" },
},
});
});

The test fails for missing required fields, invalid email formats, and incorrect types. Additional fields are allowed. Schema validation does not coerce "7" into the integer 7; see JSON Schema support.

Use this for a public user endpoint whose JSON body is an object. The response may contain fields such as id and name, but must omit credential fields:

name: Check the public user representation
method: GET
url: https://api.example.com/users/7
tests: |-
test("public user omits credential fields", () => {
expect(noodle.response.status).toBe(200);
const user = noodle.response.json();
expect(user).toMatchSchema({ type: "object" });
for (const field of ["password", "password_hash", "access_token", "refresh_token"]) {
expect(user).not.toHaveProperty(field);
}
});

An omitted field passes; a field present with null or an empty string fails. This checks only the named top-level fields. Apply the same checks to nested objects if your response contains them, and adapt the names to your contract. This recipe does not inspect every possible location or spelling of a secret.

Use this for an endpoint whose successful response is HTTP 204 with no body. This example assumes a readiness endpoint with that contract:

name: Check readiness without a response body
method: GET
url: https://api.example.com/health/ready
tests: |-
test("readiness returns 204 and an empty body", () => {
expect(noodle.response.status).toBe(204);
expect(noodle.response.text()).toBe("");
});

Read the body as text. Calling json() on an empty body would fail. A 200 response fails this contract even if its body is empty. Avoid inheriting a shared test that requires JSON from this endpoint.

Use a loop when all returned items must satisfy a condition. This endpoint should return a non-empty array of active users, each with a positive numeric ID:

name: Check active users
method: GET
url: https://api.example.com/users?active=true
tests: |-
test("every returned user is active", () => {
expect(noodle.response.status).toBe(200);
const users = noodle.response.json();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThan(0);
for (const user of users) {
expect(user.id).toBeGreaterThan(0);
expect(user.active).toBe(true);
}
});

The non-empty check prevents an empty response from passing without checking any users. Remove that check only if an empty result is valid for this endpoint. A failed matcher ends this test; other declared tests still run.

Use JavaScript when a response contains values that must agree. This example expects integer minor-unit amounts, such as {"subtotal":1000,"tax":200,"total":1200}:

name: Check order totals
method: GET
url: https://api.example.com/orders/demo-order
tests: |-
test("total equals subtotal plus tax", () => {
expect(noodle.response.status).toBe(200);
const order = noodle.response.json();
for (const amount of [order.subtotal, order.tax, order.total]) {
expect(Number.isSafeInteger(amount)).toBe(true);
expect(amount).toBeGreaterThanOrEqual(0);
}
expect(Number.isSafeInteger(order.subtotal + order.tax)).toBe(true);
expect(order.total).toBe(order.subtotal + order.tax);
});

Matching totals pass; a mismatch or non-integer amount fails. Adapt the formula if your API includes shipping, discounts, or other components.

Use related checks for a paginated list. This endpoint should return {"page":2,"items":[{"price":10},{"price":20}]}, with at most two items in ascending price order:

name: Check a sorted product page
method: GET
url: https://api.example.com/products
params:
- name: page
value: "2"
- name: limit
value: "2"
- name: sort
value: price
tests: |-
test("response matches the requested page", () => {
expect(noodle.response.status).toBe(200);
const page = noodle.response.json();
expect(page.page).toBe(2);
expect(Array.isArray(page.items)).toBe(true);
expect(page.items.length).toBeLessThanOrEqual(2);
});
test("prices are in ascending order", () => {
const items = noodle.response.json().items;
expect(Array.isArray(items)).toBe(true);
for (let index = 0; index < items.length; index++) {
expect(Number.isFinite(items[index].price)).toBe(true);
if (index > 0) {
expect(items[index].price).toBeGreaterThanOrEqual(items[index - 1].price);
}
}
});

An empty page is allowed here. Equal prices pass. These checks validate the returned page; they do not fetch other pages or prove that the full dataset is complete. Use request chaining to fetch related resources before checking them.

Check that the response reflects the request

Section titled “Check that the response reflects the request”

Use this when an update should retain the fields you sent. The endpoint should return HTTP 200 and a user object containing name and active; additional fields such as id are allowed:

name: Verify an updated user
method: PATCH
url: https://api.example.com/users/7
body_type: json
body: '{"name":"Updated Cookbook User","active":false}'
tests: |-
test("saved fields match the submitted values", () => {
expect(noodle.response.status).toBe(200);
const sent = noodle.request.body.json();
expect(noodle.response.json()).toMatchObject({
name: sent.name,
active: sent.active,
});
});

This reads the final prepared body, including substitution and pre-script changes. A different name, a missing field, or active: true fails. Compare only fields your API promises to preserve; adapt the expectation for fields the server intentionally normalizes.

Use this to catch repeated records in a list response. This example expects an array of objects with positive integer IDs, such as [{"id":7},{"id":8}]:

name: Check user IDs are unique
method: GET
url: https://api.example.com/users
tests: |-
test("user IDs are valid and unique", () => {
expect(noodle.response.status).toBe(200);
const users = noodle.response.json();
expect(Array.isArray(users)).toBe(true);
const ids = users.map(user => user.id);
for (const id of ids) {
expect(Number.isSafeInteger(id)).toBe(true);
expect(id).toBeGreaterThan(0);
}
expect(new Set(ids).size).toBe(ids.length);
});

Duplicate IDs, missing IDs, and string IDs fail. An empty array passes because it contains no duplicates; add a length check if this endpoint must return records. This checks one response, not uniqueness across other pages.

Use separate named tests when one invalid item should not hide failures in later items. This endpoint should return a non-empty array of active users with positive integer IDs, such as [{"id":7,"active":true},{"id":8,"active":true}]:

name: Report each active user separately
method: GET
url: https://api.example.com/users?active=true
tests: |-
const users = noodle.response.json();
test("response is a non-empty user list", () => {
expect(noodle.response.status).toBe(200);
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThan(0);
});
if (Array.isArray(users)) {
for (const [index, user] of users.entries()) {
test(`user ${user?.id ?? "missing ID"} at row ${index + 1} is active`, () => {
expect(user).toMatchObject({ active: true });
expect(Number.isSafeInteger(user.id)).toBe(true);
expect(user.id).toBeGreaterThan(0);
});
}
}

Results shows one list check plus a row for every user. An inactive user fails its own test while later users are still checked. Row numbers distinguish duplicate or missing IDs. Empty arrays fail the list check instead of passing with zero item tests. For large lists, use a small page to keep Results readable and stay within script result limits.

Use this for a record that should have been updated recently, such as the latest heartbeat. The response must contain created_at and updated_at as ISO timestamps with Z or an explicit timezone offset:

name: Check the latest heartbeat timestamps
method: GET
url: https://api.example.com/heartbeats/latest
tests: |-
test("timestamps are valid, ordered, and recent", () => {
expect(noodle.response.status).toBe(200);
const heartbeat = noodle.response.json();
expect(heartbeat.created_at).toMatch(/T/);
expect(heartbeat.updated_at).toMatch(/T/);
const created = noodle.time.parse(heartbeat.created_at);
const updated = noodle.time.parse(heartbeat.updated_at);
const now = noodle.time.now();
expect(created).toBeLessThanOrEqual(updated);
expect(updated).toBeGreaterThanOrEqual(now - 15 * 60 * 1000);
expect(updated).toBeLessThanOrEqual(now + 30 * 1000);
});

Invalid calendar dates and timestamps without a timezone fail parsing. The T checks also reject date-only strings. Creation must precede or equal the update; the update must be within the last 15 minutes, with up to 30 seconds of future clock skew. Adjust both windows to your API’s contract and keep the client clock synchronized. This freshness rule is unsuitable for intentionally old records. See the time API.

Use one request to check several known records against different expectations. Save this JSON as data/users.json, relative to the directory where you will run the CLI:

[
{ "user_id": 7, "expected_active": true, "expected_role": "member" },
{ "user_id": 8, "expected_active": false, "expected_role": "admin" }
]

Save the request as users/check.yml inside your api collection. The endpoint should return id, active, and role for the requested user:

name: Check a user against dataset expectations
method: GET
url: https://api.example.com/users/$user_id
tests: |-
test("user matches the dataset row", () => {
expect(noodle.iteration).not.toBeNull();
expect(noodle.response.status).toBe(200);
const expected = noodle.iteration.data;
expect(noodle.response.json()).toMatchObject({
id: expected.user_id,
active: expected.expected_active,
role: expected.expected_role,
});
});

Run from the directory containing api/ and data/:

noodle collection run ./api users/check --data ./data/users.json

The request runs twice, with a separate result for each row. Both pass only if the API matches the respective expectations. noodle.iteration.data keeps the original row even if a capture or script overwrites a RunScope value with the same name. Each row starts with a fresh scope.

JSON preserves numbers and booleans. CSV cells are strings, so a CSV version needs explicit conversions before typed comparisons. Running without a dataset leaves noodle.iteration null; ordinary substitution also needs a value for user_id. See iteration data for the F5 Runner and path rules.

Use inherited tests when every request in a folder shares a contract. Merge this block into users/folder.yml if all its endpoints return JSON on success:

tests: |-
test("successful users responses contain JSON", () => {
if (noodle.response.status < 400) {
expect(noodle.response.headers.get("Content-Type")).toContain("application/json");
expect(noodle.response.json()).toBeDefined();
}
});

Collection tests run first, then ancestor folder tests, then request tests. Every block gets a fresh sandbox and runs once per request, including each dataset row. Add this only to folders with the stated contract; an empty 204 response would fail its JSON check. Use collection settings.yml instead when the rule applies to the entire collection. For reuse across selected scopes, put the source in an external JavaScript file.

Save the first recipe as health.yml and run it manually, in the F5 Runner, or from the CLI:

noodle request run health --collection ./api

Inspect Response → Results for assertion and test outcomes. Failed assertions, failed tests, or a top-level test error make automation fail. HTTP 400 or higher also fails the request, even if a conditional test returns early or a test expecting that status passes. Pre and transport failures leave tests unevaluated. See CLI automation and CI for collection runs and JSON results.