---
title: "Testing Your API Gateway"
description:
  "The same test suite runs against local dev, a real per-branch preview
  deployment, and production. One command, one --endpoint flag. Because your
  gateway is code in Git, not console state."
canonicalUrl: "https://zuplo.com/features/testing"
sourceUrl: "https://zuplo.com/features/testing"
pageType: "feature"
generatedAt: "2026-08-18"
---

# Don't take chances with changes.

> Test your gateway at every step — locally, on a real preview deployment for
> every PR, and in production. One test suite, one command, and the only thing
> that changes is `--endpoint`. Because your gateway is code in Git, not console
> state.

## Why this matters

The gateway holds your highest-consequence logic — and it's the least tested
thing you own. Auth, rate limits, routing, request validation, error shapes. In
most stacks that behavior lives as console state in someone's browser tab,
verified by clicking around and hoping.

- **Auth logic tested, auth policy untested** — Your application's auth code has
  90% coverage. The gateway policy that actually enforces auth in production —
  attached in a console, verified by clicking around — has none. The
  highest-consequence config in your stack is the least tested thing you own.
- **The happy path hides the worst bugs** — A policy attached to the wrong route
  doesn't fail — it succeeds, for the wrong user. Requests flow, dashboards stay
  green, and every unit test passes. Only a test that asserts the rejections can
  see it.
- **Console config makes testing an afterthought** — When gateway config lives
  as database state behind a portal UI, there is no artifact a pull request can
  hold — so testing gets bolted on afterwards, as a linter, an interactive
  debugger, or a separate product. What you rarely get is a test file in the
  same repo as the config it tests.
- **Staging is a queue, not a safety net** — One shared staging environment
  means teams wait in line, config drifts from production, and test data
  collides. A pre-merge test result from an environment that doesn't match
  production doesn't mean much.

## One suite. Three endpoints.

The same test files run against your local dev server, against the real preview
deployment Zuplo builds for your branch, and against production.
`TestHelper.TEST_URL` is whatever you pass to `--endpoint` — nothing else in the
suite changes. Against production, run a small read-only smoke subset, not the
whole suite.

```typescript
// tests/auth.test.ts — the same file, every stage
import { describe, it, TestHelper } from "@zuplo/test";
import assert from "node:assert/strict";

describe("auth", () => {
  it("rejects requests without a token", async () => {
    const response = await fetch(`${TestHelper.TEST_URL}/v1/orders`);
    assert.equal(response.status, 401);
  });

  it("rejects expired tokens", async () => {
    // Fixture tokens come from the test process's environment — pass them in
    // from CI secrets or a gitignored .env, and never commit them.
    const response = await fetch(`${TestHelper.TEST_URL}/v1/orders`, {
      headers: {
        authorization: `Bearer ${TestHelper.environment.EXPIRED_JWT}`,
      },
    });
    assert.equal(response.status, 401);
  });
});
```

```bash
# Local → preview → production
# 1. Local dev — zuplo dev running in another terminal
npx zuplo test --endpoint http://localhost:9000

# 2. The preview deployment Zuplo built for your branch
npx zuplo test --endpoint https://my-branch-my-project.zuplo.app

# 3. Production — read-only smoke tests, selected by name
npx zuplo test --endpoint https://api.example.com --filter "smoke"
```

Setup: none. `@zuplo/test` arrives with the `zuplo` package, and assertions come
from Node's built-in `node:assert/strict`. No assertion library to install.

- [Testing docs](https://zuplo.com/docs/articles/testing)
- [GitOps for gateways](/features/gitops)

## Every pull request gets a real gateway, not a mock

Push a branch and Zuplo deploys a full gateway — the complete runtime, on the
same edge network as production, at its own URL. Your tests run against a real
deployment before merge, so a pre-merge result actually predicts production
behavior. And the tests sit in your repo, next to the config they test.

```text
my-gateway/
├── config/
│   ├── routes.oas.json     # routes + request validation (OpenAPI)
│   └── policies.json       # auth, rate limits, transforms
├── modules/
│   └── custom-policy.ts    # custom TypeScript policies
└── tests/
    ├── auth.test.ts        # reviewed in the same PR
    └── rate-limit.test.ts  # as the config it tests
```

- Branch pushed → gateway deployed at its own URL
- Preview runs the full runtime on the production edge
- Policy change + the test that pins it, in one PR diff
- No GUI-authored test documents, no declarative spec format — files in Git

## Test what actually breaks

The interesting assertions at a gateway are the rejections. These four defects
don't show up in unit tests or happy-path checks — and they're all one `fetch`
away.

```typescript
// Expired token → 401
it("rejects an expired token", async () => {
  const response = await fetch(`${TestHelper.TEST_URL}/v1/orders`, {
    headers: {
      authorization: `Bearer ${TestHelper.environment.EXPIRED_JWT}`,
    },
  });
  assert.equal(response.status, 401);
});
```

```typescript
// Another tenant's ID → not found
it("hides another tenant's objects", async () => {
  // Valid token for tenant A, requesting tenant B's order.
  // A happy-path test passes here — for the wrong user.
  const response = await fetch(
    `${TestHelper.TEST_URL}/v1/orders/${TENANT_B_ORDER_ID}`,
    { headers: { authorization: `Bearer ${TENANT_A_TOKEN}` } },
  );
  assert.equal(response.status, 404);
});
```

```typescript
// Over quota → 429 + Retry-After
it("rate limits over-quota callers", async () => {
  // A dedicated key per test = its own bucket, no shared
  // windows, no sleeping. Send requests one at a time up to a
  // generous bound and stop at the first 429 — never assert on
  // an exact boundary, and never fire a concurrent burst.
  let response!: Response;
  for (let i = 0; i < 50; i++) {
    response = await fetch(`${TestHelper.TEST_URL}/v1/orders`, {
      headers: { authorization: `Bearer ${THIS_TEST_ONLY_KEY}` },
    });
    if (response.status === 429) break;
  }
  assert.equal(response.status, 429);
  assert.notEqual(response.headers.get("retry-after"), null);
});
```

```typescript
// Malformed body → 400 problem+json
it("rejects a malformed body with a problem detail", async () => {
  const response = await fetch(`${TestHelper.TEST_URL}/v1/orders`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${TOKEN}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({ quantity: "not-a-number" }),
  });
  assert.equal(response.status, 400);
  assert.ok(
    response.headers.get("content-type")?.includes("application/problem+json"),
  );
});
```

## In the box. Every plan.

- **In the box, on every plan** — `zuplo test` ships with the Zuplo CLI — no
  testing tier, no separate product, no add-on license. Elsewhere the
  first-party equivalents often sit behind a paywall: KrakenD's e2e runner is
  Enterprise-only, Gravitee's Debug mode is Enterprise-only, and Azure's policy
  debugger is Developer-tier, the tier Microsoft excludes from its production
  SLA.
- **Plain TypeScript, standard tools** — Tests use `fetch` and Node's built-in
  `node:assert/strict`. `describe`, `it`, `hooks`, and `TestHelper` come from
  `@zuplo/test` — no proprietary DSL, no GUI-authored test documents, no
  declarative JSON spec format to learn, and no assertion library to install.
  Under the hood it's the Node.js test runner.
- **Filter and skip by name** — `--filter` selects tests by name (a plain string
  or a regex), `--skip-filter` excludes them, and the two compose. Tag your
  read-only smoke tests in their names and run just that subset against
  production.

## Green in CI means green in production

Zuplo's GitHub integration fires a `deployment_status` event after deploying
your branch. Trigger a workflow on it, run the suite against the preview URL,
and make the workflow a required status check — a failing gateway test now
blocks the merge.

One discipline the snippet bakes in: poll a health route before running tests
instead of sleeping. A deploy reporting success is not the same as a gateway
ready to serve traffic, and a blanket retry on a flaky suite is not a fix — it's
the pipeline lying about readiness.

```yaml
# .github/workflows/test.yaml
name: Test Deployment

on:
  deployment_status:

jobs:
  test:
    if: |
      github.event.deployment_status.state == 'success' &&
      github.event.deployment_status.environment_url != ''
    runs-on: ubuntu-latest
    env:
      ENDPOINT: ${{ github.event.deployment_status.environment_url }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci

      # Poll for readiness rather than guessing with one fixed sleep.
      # A deploy reporting success is not the same as a gateway serving
      # traffic, and you cannot know in advance how long the gap is.
      - name: Wait for the gateway to be ready
        run: |
          for i in $(seq 1 30); do
            curl -sf "$ENDPOINT/health" > /dev/null && exit 0
            sleep 2
          done
          echo "Gateway not ready after 60s" >&2; exit 1

      - name: Run tests
        run: npx zuplo test --endpoint "$ENDPOINT"
```

- [CI testing docs](https://zuplo.com/docs/articles/github-deployment-testing)
- [Unlimited environments](/features/unlimited-environments)

## Testable by architecture, not by add-on

- **A real deployment, not a mock** — Every branch deploys as a full Zuplo
  gateway on the same edge network as production, with its own URL. Your
  pre-merge tests hit real policy execution, real routing, real error handling —
  so a green check means something.
- **Config and tests in one PR** — `routes.oas.json`, `policies.json`,
  `modules/*.ts`, and `tests/*.test.ts` live in the same repo and ship in the
  same pull request. The reviewer sees the policy change and the test that pins
  its behavior, side by side.
- **Testable because it's code** — Config-shaped failures — a wrong route, a
  policy on the wrong path, a missing env var — are a defect class unit tests
  can't see. Git-native config is the precondition for testing them: there's an
  artifact to test before deploy, not just a running system to poke afterward.
- **One suite, three targets, no add-on** — As far as we can establish from
  vendor documentation, no other API gateway offers this conjunction: a
  first-party test command at no extra tier, tests as ordinary TypeScript in
  your own repo beside the config they test, and the identical suite running
  unchanged against localhost, a per-PR preview deployment, and production by
  changing only the `--endpoint` flag. Individual clauses have competitors; the
  conjunction doesn't.

## FAQ

**How do I test an API gateway?** Treat the gateway like the code it is: write
tests that make real HTTP requests and assert on real responses — auth
rejections, rate-limit responses, routing, error shapes. With Zuplo, tests are
plain TypeScript files in your repo's `tests/` folder, run by the `zuplo test`
command against any live endpoint. Run them against localhost while developing,
against the preview deployment Zuplo builds for every branch before merge, and
as a small smoke set against production.

**Can I run the same tests against production?** Yes — the suite retargets with
a single flag: `zuplo test --endpoint https://api.example.com`. Nothing else
changes. In practice, run the full suite against local dev and preview
deployments, and a small set of read-only smoke assertions against production.
Use the `--filter` flag to select the smoke subset by test name.

**Do I need a separate testing tool?** No, and there is nothing to install. The
`zuplo test` command ships with the Zuplo CLI on every plan — no testing tier,
no separate product to license. Tests are plain TypeScript: `describe`, `it`,
hooks and `TestHelper` come from the `@zuplo/test` types, which arrive
transitively with the `zuplo` package, and assertions use Node's built-in
`node:assert/strict`. No assertion library, no extra dev dependency. If you
already know how to write a TypeScript test, you already know how to test your
gateway.

**How do I test auth policies?** Test the rejections — that's where auth bugs
live. Assert that a missing token gets a 401, an expired token gets a 401, a
token with the wrong audience or scope gets rejected, and a valid token
requesting another tenant's object gets a 403 or 404. A happy-path test passes
even when a policy is attached to the wrong route; the negative cases are what
catch it.

**How do I test rate limits without flaky tests?** Make them deterministic: give
each test its own rate-limit bucket (a unique API key or user), so tests never
share a window or interfere with each other. Send requests one at a time in a
loop with a generous upper bound and stop at the first 429, rather than firing a
burst or asserting on an exact boundary. Never sleep through a real rate-limit
window — that's the textbook flaky test. Assert the whole response: status 429
plus the `Retry-After` header, not just the status code.

**What's a preview environment?** Every Git branch in a Zuplo project deploys as
a real gateway with its own URL, running the full Zuplo runtime on the same edge
network as production. It's not a mock or a staging shim — it's a live
deployment your tests, reviewers, and QA can hit before the change merges. The
GitHub integration posts the URL on your PR automatically.

**Can I test locally without deploying?** Yes. The `zuplo dev` command runs your
gateway at `http://localhost:9000`, and
`zuplo test --endpoint http://localhost:9000` runs the suite against it — the
fastest feedback loop. One caveat: API key authentication and rate limiting need
a connection to Zuplo cloud services, so link your local project to a Zuplo
project with `zuplo link` to exercise those policies locally.

**How do I block a merge when tests fail?** Trigger a GitHub Actions workflow on
the `deployment_status` event Zuplo fires after deploying your branch, run
`zuplo test` against the preview URL, and mark the workflow as a required status
check under branch protection. A failing gateway test then blocks the merge
exactly like a failing unit test. Poll a health route before running tests
rather than guessing with one fixed sleep, so readiness races never show up as
flakes.

## Next steps

- Start for Free — https://portal.zuplo.com/signup
- Read the docs — https://zuplo.com/docs/articles/testing
- Talk to a Solutions Engineer — /schedule-call
