DevTools
Back to Blog
API Regression Testing Checklist: 30 Edge Cases Most Teams Miss

API Regression Testing Checklist: 30 Edge Cases Most Teams Miss

DevTools TeamDevTools Team

Regression failures almost never come from the “happy path” you recorded last week. They come from contract edge cases that only show up under load, partial outages, retries, or when old clients hit a newly deployed backend.

This checklist is aimed at teams doing YAML-based API regression testing in Git (reviewed in PRs, executed locally and in CI) where determinism matters more than UI clicks. If you are migrating from Postman/Newman or evaluating Bruno, the main advantage of keeping these cases in native YAML is simple: the diff is the review. No opaque collection JSON, no UI-locked state, no “someone changed a script” mystery.

If you want a reference baseline for assertions and Git-friendly structure, see:

A simple diagram of a YAML API regression flow with four boxes connected by arrows: “Auth (token)”, “Request (with variables)”, “Assertions (status, headers, JSONPath)”, and “Artifacts (JUnit, logs)”, emphasizing Git review and CI execution.

How to use this checklist (without creating flaky tests)

Treat each edge case below as a small, isolated flow:

  • Prefer deterministic fixtures (seeded users, stable product IDs, known quotas) over random data.
  • Make request chaining explicit (capture IDs and tokens, then assert on the next step).
  • Avoid “assert full JSON equals” for large responses, assert shape + invariants instead.
  • In CI, run edge-case flows with controlled concurrency, especially for rate-limit and idempotency checks.

For CI setup patterns, refer to the GitHub Actions guide (this article focuses on what to test, not pipeline wiring):

The 30 edge cases most teams miss (categorized)

Each row is framed as: what usually breaks and what to assert in a JSON API regression test.

Auth (5)

These are the failures that only show up after deploys, key rotations, or account lifecycle changes.

#Edge caseWhat usually breaksWhat to assert (contract-level)
1Expired access tokenAPI returns inconsistent status codes (200 with error body, 500), or refresh flow silently failsProtected endpoint returns 401 (or 403, if that is your policy) with a stable error code; fresh token succeeds
2Revoked user/session“Deleted user” still accesses cached auth, or returns 404 and hides auth issue401/403 with explicit “revoked/disabled” semantics in error payload
3Insufficient scopeAPI incorrectly returns 404, or over-permits403 with a machine-readable permission/scope indicator
4Token audience/issuer mismatchAcceptance of tokens minted for a different service/environment401 with explicit auth error code (not a generic 500)
5Clock skew around exp/nbfRandom failures in CI runners with slightly skewed clocksEither documented skew tolerance works, or error response is deterministic and parseable

Copy-paste YAML snippet: expired token, then re-auth and retry

This pattern avoids conditional logic by using a known expired token fixture (in CI, provide it via secret/env var).

# flows/auth_expired_token.yaml
# Purpose: prove the API rejects expired tokens deterministically, and that a fresh login restores access.

env:
  BASE_URL: '{{BASE_URL}}'
  EXPIRED_ACCESS_TOKEN: '{{EXPIRED_ACCESS_TOKEN}}'

flows:
  - name: ExpiredTokenFlow
    variables:
      - name: USERNAME
        value: '{{USERNAME}}'
      - name: PASSWORD
        value: '{{PASSWORD}}'
    steps:
      - request:
          name: RejectExpired
          method: GET
          url: '{{BASE_URL}}/v1/me'
          headers:
            Accept: application/json
            Authorization: Bearer {{EXPIRED_ACCESS_TOKEN}}

      - js:
          name: ValidateRejection
          code: |
            export default function(ctx) {
              const status = ctx.RejectExpired?.response?.status;
              if (status !== 401) throw new Error(`Expected 401, got ${status}`);
              return { rejected: true };
            }
          depends_on: RejectExpired

      - request:
          name: Login
          method: POST
          url: '{{BASE_URL}}/v1/login'
          headers:
            Accept: application/json
            Content-Type: application/json
          body:
            username: '{{USERNAME}}'
            password: '{{PASSWORD}}'
          depends_on: ValidateRejection

      - js:
          name: ValidateLogin
          code: |
            export default function(ctx) {
              const resp = ctx.Login?.response;
              if (resp?.status !== 200) throw new Error(`Expected 200, got ${resp?.status}`);
              if (!resp?.body?.access_token) throw new Error("access_token missing");
              return { validated: true };
            }
          depends_on: Login

      - request:
          name: AcceptFresh
          method: GET
          url: '{{BASE_URL}}/v1/me'
          headers:
            Accept: application/json
            Authorization: Bearer {{Login.response.body.access_token}}
          depends_on: ValidateLogin

      - js:
          name: ValidateFreshAccess
          code: |
            export default function(ctx) {
              const resp = ctx.AcceptFresh?.response;
              if (resp?.status !== 200) throw new Error(`Expected 200, got ${resp?.status}`);
              if (!resp?.body?.id) throw new Error("id missing from /me response");
              return { userId: resp.body.id };
            }
          depends_on: AcceptFresh

Notes for Git review:

  • Keep the expired token fixture separate from “normal auth” flows so you do not accidentally depend on token timing.
  • Make your error contract assertions explicit (status, content-type, and a minimal error shape).

Rate limiting (4)

The edge cases here are about honoring server guidance and keeping CI deterministic under parallelism.

Reference for semantics: RFC 6585 (429 Too Many Requests) and RFC 9110 (Retry-After header).

#Edge caseWhat usually breaksWhat to assert (contract-level)
6429 includes Retry-AfterClients retry immediately and amplify outagesOn 429, Retry-After exists and is a valid delta-seconds or HTTP date
7Rate-limit headers consistentConflicting limit/remaining/reset values across hopsHeaders, if present, are parseable and monotonic within a single response
8Burst limit under concurrencySerial tests pass, parallel CI fails unpredictablyWith N parallel requests, you deterministically see some 429s and no 5xx
9Per-tenant isolationOne tenant’s test suite burns another’s quotaSeparate API keys do not affect each other’s observed rate headers

Practical tip: run these as a dedicated CI job with pinned concurrency so the test itself does not become the source of noise. DevTools’ Go runner speed makes it easy to generate bursts, but you still want controlled bursts.

If you need deeper CI patterns for retries and rate limits, see API Testing in GitHub Actions: Secrets, Auth, Retries, Rate Limits.

Pagination drift (4)

If your pagination tests only validate “page 1 returns 20 items”, you are not testing drift. Drift happens when the dataset changes between requests.

#Edge caseWhat usually breaksWhat to assert (contract-level)
10Inserts between pagesDuplicate items or skipped items across page boundariesCursor-based pagination does not repeat IDs when a new record is inserted
11Deletes between pagesCursor becomes invalid, or server returns 500Documented behavior is stable (e.g., 400/410 with error code), not a generic 500
12Unstable ordering tiesRandomly reordered results across identical queriesSort is deterministic (e.g., created_at, id) and remains stable under ties
13Page size boundariesUnbounded queries hurt performance, inconsistent errorslimit max enforced, limit=0 and negative values handled deterministically

Request chaining tip: capture the first page’s IDs, then assert that the second page does not include them. This is an invariants-first approach that is stable in Git and stable in CI.

Null vs missing fields (4)

Teams often regress here during refactors because “nullable vs optional” is under-specified and tests are too strict or too loose.

Reference: RFC 8259 (The JSON Data Interchange Syntax).

#Edge caseWhat usually breaksWhat to assert (contract-level)
14null vs missingClients treat missing as “default” and null as “explicitly blank”, backend flips the meaningContract is explicit: field is either optional (may be missing) or nullable (may be null), and tests enforce it
15Empty array vs missing arrayUI and downstream jobs mis-handle [] vs absentFor collection fields, your API uses one convention consistently
16false vs missingBoolean fields become tri-state by accidentIf tri-state is intended, document it and assert it; otherwise never omit booleans
170 vs missingCounters and monetary fields regress into “missing means zero” ambiguityNumeric fields either always present (including 0) or clearly optional with defaults

Where this bites in Git review: Postman/Newman tests often do full-body equality or implicit JS truthiness checks. In YAML, prefer explicit JSONPath assertions for presence and type so reviewers can see exactly what changed.

Backward compatibility (4)

Backward compatibility is where over-specified tests become a liability. You want tests that catch real contract breaks, not tests that pin incidental fields.

#Edge caseWhat usually breaksWhat to assert (contract-level)
18New enum value in responseOlder clients crash on unexpected enumTests allow unknown enum values (or assert “enum is one of known + unknown”) depending on your policy
19Extra fields addedStrict decoders or strict tests failTests assert required fields and ignore additional properties
20Error schema consistency across 4xx/5xx500 returns HTML, 4xx returns JSON, clients fail to parseNon-2xx responses keep application/json and a stable error envelope
21Newly added request field becomes requiredOld clients start failing post-deployMissing new field yields default behavior or a clear, versioned error, never a 500

This is one place where YAML in Git shines versus UI-driven tools: reviewers can see you changed “assert full body” into “assert required fields + invariants”, which is a compatibility decision.

Idempotency (4)

Idempotency is not just “use PUT”. It is “what happens when the network lies”, especially in CI and under retries.

#Edge caseWhat usually breaksWhat to assert (contract-level)
22POST retry with Idempotency-Key after timeoutDuplicate resources createdRetrying the same request with same key returns the same resource (or same operation result)
23Same key, different bodyServer incorrectly replays prior response or creates a new resourceConflict response (409/422) with clear message that key is already used with different payload
24PUT repeatedETags/versioning drift, server increments revision on no-opSame PUT twice produces the same representation and stable ETag/version semantics
25PATCH repeatedPatch accidentally becomes “incremental” (non-idempotent)Either PATCH is idempotent by design and tested as such, or it is explicitly documented as non-idempotent

Copy-paste YAML snippet: Idempotency-Key replay

This is a regression test you can run against staging without heavy fixtures. It uses request chaining to assert that the server returns the same id on replay.

# flows/idempotency_post_replay.yaml

env:
  BASE_URL: '{{BASE_URL}}'
  API_TOKEN: '{{API_TOKEN}}'

flows:
  - name: IdempotencyReplayFlow
    variables:
      - name: run_id
        value: '{{run_id}}'
    steps:
      - request:
          name: CreateOnce
          method: POST
          url: '{{BASE_URL}}/v1/orders'
          headers:
            Accept: application/json
            Authorization: Bearer {{API_TOKEN}}
            Content-Type: application/json
            Idempotency-Key: 'regression-{{run_id}}'
          body:
            currency: "USD"
            amount_cents: 1234

      - js:
          name: ValidateCreate
          code: |
            export default function(ctx) {
              const resp = ctx.CreateOnce?.response;
              if (resp?.status !== 201) throw new Error(`Expected 201, got ${resp?.status}`);
              if (!resp?.body?.id) throw new Error("id missing from create response");
              return { orderId: resp.body.id };
            }
          depends_on: CreateOnce

      - request:
          name: ReplaySameKey
          method: POST
          url: '{{BASE_URL}}/v1/orders'
          headers:
            Accept: application/json
            Authorization: Bearer {{API_TOKEN}}
            Content-Type: application/json
            Idempotency-Key: 'regression-{{run_id}}'
          body:
            currency: "USD"
            amount_cents: 1234
          depends_on: ValidateCreate

      - js:
          name: ValidateReplay
          code: |
            export default function(ctx) {
              const resp = ctx.ReplaySameKey?.response;
              if (resp?.status !== 200) throw new Error(`Expected 200, got ${resp?.status}`);
              const originalId = ctx.CreateOnce?.response?.body?.id;
              if (resp?.body?.id !== originalId) {
                throw new Error(`Replay returned different id: ${resp?.body?.id} vs ${originalId}`);
              }
              return { idempotent: true };
            }
          depends_on: ReplaySameKey

Git hygiene tip: keep the idempotency key generation deterministic per run (for example run_id), so parallel CI shards do not collide.

Partial failures (5)

Partial failure behavior is where “status code only” testing becomes meaningless. You need to validate the error envelope and per-item results.

Reference: RFC 4918 (207 Multi-Status).

#Edge caseWhat usually breaksWhat to assert (contract-level)
26207 Multi-Status for batch endpointsClient assumes 2xx means full success207 includes per-item status, and each item is correlated to input
27200 with per-item errors array“Success” hides failures, monitoring misses itResponse contains explicit errors/failed section with counts and identifiers
28500 with JSON bodyInfra/proxy injects HTML error pagesOn 5xx, content-type remains JSON and includes request ID/correlation ID
29502/504 with structured bodyGateways vary between empty body, HTML, and JSONIf structured errors are promised, they appear consistently, otherwise tests accept empty but never HTML
30Partial write on server error500 returned but side effects happened, leaving orphansEither transactionality is enforced (no partial writes) or recovery is possible (operation ID to reconcile)

Copy-paste YAML snippet: 207 Multi-Status batch response

This example asserts both the HTTP status and the per-item contract. Adjust JSONPaths to your payload shape.

# flows/batch_partial_failure_207.yaml

env:
  BASE_URL: '{{BASE_URL}}'
  API_TOKEN: '{{API_TOKEN}}'

flows:
  - name: BatchPartialFailureFlow
    steps:
      - request:
          name: BatchGet
          method: POST
          url: '{{BASE_URL}}/v1/widgets:batchGet'
          headers:
            Accept: application/json
            Authorization: Bearer {{API_TOKEN}}
            Content-Type: application/json
          body:
            ids:
              - "known-good-id"
              - "known-missing-id"

      - js:
          name: ValidateBatchResponse
          code: |
            export default function(ctx) {
              const resp = ctx.BatchGet?.response;
              if (resp?.status !== 207) throw new Error(`Expected 207, got ${resp?.status}`);
              const results = resp?.body?.results;
              if (!results) throw new Error("results missing");
              if (!results[0]?.status) throw new Error("results[0].status missing");
              if (!results[1]?.status) throw new Error("results[1].status missing");
              return { resultCount: results.length };
            }
          depends_on: BatchGet

Why YAML-first matters for these edge cases (and where Postman/Newman/Bruno often struggle)

These cases are not hard because they are conceptually advanced, they are hard because they require discipline:

  • Deterministic diffs: In Postman, you tend to accumulate "test scripts" and implicit collection state. Reviewers rarely catch subtle logic changes. With YAML in Git, a change from checking status !== 200 to status !== 200 && status !== 207 in a JS validation node is explicit.
  • Request chaining is visible: Node references like {{Login.response.body.token}} live next to the request that uses them, which makes drift bugs easier to diagnose in PRs.
  • CI portability: Newman runs Postman’s model in CI, but you still carry Postman’s collection JSON and scripting conventions. Bruno is local-first, but still uses a custom file format. Native YAML flows are easier to audit, template, and enforce via repo policies.

If you are actively migrating, the most practical approach is: record traffic (HAR), generate a baseline flow, then add the edge-case flows above as small, reviewable YAML files. DevTools supports the HAR to YAML workflow directly (see Generate a HAR file in Chrome (Safely) and HAR to YAML: Generate API Regression Tests for CI).

The “PR gate” version of this checklist

When someone changes an endpoint, require them to answer, in the PR description, which of these categories they validated:

  • Auth behavior (including failure modes)
  • Rate limit behavior under concurrency
  • Pagination stability under drift
  • Null vs missing contract
  • Backward compatibility for old clients
  • Idempotency under retries
  • Partial failure envelopes

That is the difference between "we have tests" and "we have regression coverage".

Many of these edge cases are most effective when tested as part of a multi-step workflow rather than in isolation. For a guide on chaining these checks into full end-to-end API test flows, see: End-to-End API Testing: The Complete Guide.