Zuplo
Testing

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.

Same assertions, rising fidelity

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.

TypeScripttests/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);
  });
});
TerminalLocal → 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.

Real deployments, real repos

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.

Tests live next to the config they test
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
What to assert

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.

TypeScriptExpired 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);
});
TypeScriptAnother 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);
});
TypeScriptOver 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);
});
TypeScriptMalformed 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"),
  );
});
No testing tier

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.

Block bad merges

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.

YAMLRun tests after every deploy — with a readiness poll
# .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"
What makes Zuplo different

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.

Frequently Asked Questions

Common questions about testing API gateways with Zuplo.

Test at every step

Free Zuplo project, write a test, run it against localhost — then watch the same suite run against a real preview deployment on your first PR.