ZuploZuplo
LoginStart for Free
  • Documentation
  • API Reference
Getting Started
    Develop in the portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
    Develop locally with the CLI
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
Concepts
API Management
AI Gateway
MCP Gateway
MCP Server
Developer Portal
Development
    CORSEnvironment VariablesBranch-Based DeploymentsTroubleshootingGitOps vs TerraformCustom Code
    Testing
      Get startedTest recipesPreview environmentsTest deployments
    Local Development
    Guides
Deploying & Source Control
Analytics
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsVersion Support PolicySecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Testing

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
const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString();

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
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); describe("auth policy", () => { it("rejects requests with no token", async () => { const response = await fetch(url("/v1/orders/ord_1001")); expect(response.status).to.equal(401); }); it("rejects expired tokens", async () => { const response = await fetch(url("/v1/orders/ord_1001"), { headers: { Authorization: `Bearer ${TestHelper.environment.EXPIRED_JWT}`, }, }); expect(response.status).to.equal(401); }); it("rejects tokens issued for a different audience", async () => { const response = await fetch(url("/v1/orders/ord_1001"), { headers: { Authorization: `Bearer ${TestHelper.environment.WRONG_AUDIENCE_JWT}`, }, }); expect(response.status).to.equal(401); }); it("does not serve another tenant's order", async () => { // Tenant A's valid token, tenant B's order ID. const response = await fetch(url("/v1/orders/ord_2002"), { headers: { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, }, }); expect([403, 404]).to.include(response.status); }); });

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
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); describe("rate limit policy", () => { it("returns 429 with Retry-After when the limit is exceeded", async () => { // A unique bucket per run: no other test, and no previous run of this // test, can have consumed any of this budget. const clientId = `rl-test-${crypto.randomUUID()}`; let response!: Response; for (let i = 0; i < 50; i++) { response = await fetch(url("/v1/search?q=gateways"), { headers: { "client-id": clientId }, }); if (response.status === 429) break; } expect(response.status).to.equal(429); expect(response.headers.get("retry-after")).to.not.be.null; }); });

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.all can 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-After header, 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:

TerminalCode
npm install --save-dev zod
Code
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; import { z } from "zod"; const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); const Order = z.object({ id: z.string(), status: z.enum(["pending", "shipped", "cancelled"]), total: z.number(), customerId: z.string(), note: z.string().optional(), }); describe("order shape", () => { it("returns a well-formed order", async () => { const response = await fetch(url("/v1/orders/ord_1001"), { headers: { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, }, }); expect(response.status).to.equal(200); const result = Order.safeParse(await response.json()); expect(result.success, JSON.stringify(result.error?.issues, null, 2)).to.be .true; }); });

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:

TerminalCode
npm install --save-dev ajv
Code
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; import Ajv from "ajv"; import oas from "../config/routes.oas.json"; const ajv = new Ajv({ strict: false }); const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); const orderSchema = oas.paths["/v1/orders/{orderId}"].get.responses["200"].content[ "application/json" ].schema; describe("OpenAPI conformance", () => { it("GET /v1/orders/{orderId} matches its declared schema", async () => { const response = await fetch(url("/v1/orders/ord_1001"), { headers: { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, }, }); expect(response.status).to.equal(200); const body = await response.json(); const valid = ajv.validate(orderSchema, body); expect(valid, ajv.errorsText(ajv.errors)).to.be.true; }); });

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
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; import { z } from "zod"; const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); // RFC 9457 problem details. const Problem = z.object({ type: z.string(), title: z.string(), status: z.number(), detail: z.string(), }); describe("error contract", () => { it("returns problem+json for a malformed body", async () => { const response = await fetch(url("/v1/orders"), { method: "POST", headers: { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, "content-type": "application/json", }, body: JSON.stringify({ quantity: "several" }), }); expect(response.status).to.equal(400); expect(response.headers.get("content-type")).to.include( "application/problem+json", ); const result = Problem.safeParse(await response.json()); expect(result.success, JSON.stringify(result.error?.issues, null, 2)).to.be .true; }); });

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
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); describe("routing", () => { it("404s an unknown path", async () => { const response = await fetch(url("/v1/does-not-exist")); expect(response.status).to.equal(404); }); it("keeps the legacy alias working", async () => { const response = await fetch(url("/orders/ord_1001"), { headers: { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, }, }); expect(response.status).to.equal(200); }); it("answers a CORS preflight", async () => { const response = await fetch(url("/v1/orders"), { method: "OPTIONS", headers: { Origin: "https://example.com", "Access-Control-Request-Method": "GET", }, }); expect(response.headers.get("access-control-allow-origin")).to.exist; }); });

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
import { afterEach, describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); const auth = { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, }; describe("orders", () => { const created: string[] = []; afterEach(async () => { while (created.length > 0) { await fetch(url(`/v1/orders/${created.pop()}`), { method: "DELETE", headers: auth, }); } }); it("returns an order it created", async () => { const create = await fetch(url("/v1/orders"), { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ sku: "widget", quantity: 1 }), }); expect(create.status).to.equal(201); const { id } = await create.json(); created.push(id); const read = await fetch(url(`/v1/orders/${id}`), { headers: auth }); expect(read.status).to.equal(200); }); });

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.

Related

  • Get started with zuplo test
  • Testing preview environments
  • Testing overview
Edit this page
Last modified on August 21, 2026
Get startedPreview environments
On this page
  • Auth rejections
  • Rate limits, deterministically
    • Distributed counters are eventually consistent
  • Response shape
  • OpenAPI conformance
  • The error contract
  • Routing and CORS
  • Test data hygiene
  • Related
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript
TypeScript