Gateway test recipes
The interesting assertions at a gateway are the rejections. A suite that only sends well-formed requests with correct credentials passes while a policy is attached to the wrong route — because the request still succeeds, for the wrong caller.
Each recipe below is a complete file you can drop into tests/. They assume the
setup from Get started with zuplo test, which
installs chai for the assertions. The two schema recipes add one library each
and say so inline.
All of them use the same helper for building URLs:
Code
Auth rejections
Test every way a request can be turned away, and finish with the case that matters most: a valid token from one tenant asking for another tenant's object. Broken object-level authorization is invisible to happy-path testing because the request succeeds.
Code
Run the same file against every route that shares the policy. A route added later without the policy attached is exactly the defect this catches.
Rate limits, deterministically
Most rate limit tests are flaky for two reasons: tests share a bucket, and tests sleep through real time windows. Fix both.
Give each test its own bucket by generating a fresh value for whatever the
policy keys on — an API key, a client ID, a header. Then send requests one at a
time and stop at the first 429.
Code
Four details are load-bearing:
- Point the test at a low limit. Test a route configured for a handful of requests, not your production 10,000/minute tier. The behavior you are verifying — that the policy is attached, keyed correctly, and returns the right response — is identical either way. A test that has to send ten thousand requests to prove it is slow, expensive, and unkind to shared infrastructure. If your real limits are high, add a test-only route with a small limit rather than exercising the big one.
- A generous upper bound, not the exact limit. Asserting "request N succeeds and request N+1 fails" breaks the moment the configured limit changes or anything else touches the window.
- Sequential, not concurrent. Firing a burst with
Promise.allcan under-count against a distributed bucket, so the burst sometimes slips through and the test fails for no reason you can reproduce. - Assert the whole contract. The status and the
Retry-Afterheader, both of which are settled standards from RFC 6585.
Distributed counters are eventually consistent
Rate limiting runs at the edge, across regions, and the counter behind it converges rather than updating everywhere at once. Two consequences for tests:
- Never assert an exact count. "The 11th request is the one that 429s" is not a property the system guarantees. A request that lands in a region whose view of the counter is a moment stale can succeed past the nominal limit, and a retry can be counted twice. Assert that a 429 arrives within a bounded number of attempts, which is what the loop above does.
- Give propagation a moment. Immediately after the first request opens a window, a request served from a different region may not see it yet. If a test needs the counter to be shared, poll for the condition with a deadline instead of sleeping a fixed amount and hoping.
The same reasoning applies to anything else backed by distributed state — caches, quotas, and metering all trade exactness for latency. Test the behavior (a limit is enforced, a cached response is returned) rather than the arithmetic.
Do not assert on RateLimit-Limit, RateLimit-Remaining, or RateLimit-Reset.
Zuplo's Rate Limit policy emits
Retry-After and nothing else, so those assertions fail. They are also still an
IETF Internet-Draft rather than a standard, and their names and semantics have
changed between draft revisions.
Response shape
Asserting a couple of fields leaves most of the response untested. A schema checks the whole body in one line and tells you exactly which field is wrong when it fails.
Zod is the recommended way to do this. The schema is ordinary TypeScript, so there is no second language to learn and no build step:
Code
Code
Two things to know. safeParse returns a result rather than throwing, so the
assertion message can carry every problem at once instead of only the first. And
z.infer<typeof Order> gives you a TypeScript type for free, so the rest of the
test is typed against the same definition it validated.
Zod is strict about extra keys only if you ask. By default, unknown properties are stripped and the parse still succeeds, which is usually what you want at a gateway — an upstream adding a field should not fail your suite. Opt into a strict object schema when you specifically want an unexpected field to fail.
OpenAPI conformance
The recipe above tests against a shape you wrote by hand, which can drift from
the spec. When you want config/routes.oas.json itself to be the oracle — it
is the gateway configuration, so this closes the loop between the spec and
the deployed behavior — validate against the document instead.
That means JSON Schema, and Zod is the wrong tool for it. Zod schemas are authored in TypeScript, not derived from a JSON Schema document. Use a JSON Schema validator:
Code
Code
The path, method, and status key in the orderSchema lookup must exist in your
own routes.oas.json — TypeScript infers the type of an imported JSON module
from the file's actual contents, so a typo is a compile error rather than a
runtime surprise.
Pick one: Zod when you want a readable assertion about a response, Ajv when you want the spec to be the thing under test.
The error contract
Errors are part of your API's contract, and they are the part a gateway is most likely to change without anyone noticing. A policy swap can turn a structured problem response into a bare 500, and no dashboard flags it.
Zuplo returns RFC 9457 problem details
with a content-type of application/problem+json. Assert the shape, not the
status alone:
Code
A hand-rolled ["type", "title"].every((key) => key in problem) passes on
{ type: 123 } and, when it does fail, tells you only that true was expected.
The schema checks the types too and names the offending field.
Routing and CORS
Routing bugs are cheap to catch and expensive to miss. Assert that unknown paths return 404, that a deprecated alias still resolves, and that the preflight response carries the headers a browser needs.
Code
An alias route is worth its own test for the same reason as the cross-tenant case: it is the route most likely to be missing a policy that the canonical route has.
Test data hygiene
Two tests that touch the same order, the same API key, or the same rate-limit bucket eventually run at the same time and corrupt each other. Test files run in parallel by default.
Each test creates what it needs through the API, owns it exclusively, and cleans up after itself:
Code
Two rules follow from this:
- No order dependency. If test B passes only because test A ran first, you have one long test with a hidden seam, and any filter or reorder exposes it.
- A retry is not a fix. If a test needs a rerun to pass, it has a shared resource, a sleep, or the pipeline is reporting readiness before the gateway is serving. See testing GitHub deployments for the readiness poll.