Testing

Get started with zuplo test

This guide takes a Zuplo project from no tests to a green test run against a local dev server, in three steps. It covers where test files live, what TestHelper gives you, and the CLI flags worth knowing. The only install is one assertion library.

Every test zuplo test runs is an integration test: it makes a real HTTP request to a real running gateway and asserts on the real response. The endpoint is a command-line flag, so the same files run against local dev, a preview deployment, or production.

  1. Install an assertion library

    describe, it, the lifecycle hooks, and TestHelper come from @zuplo/test, which arrives transitively with the zuplo package. The CLI requires Node.js 24.0.0 or later, which every sample here assumes.

    Assertions in these docs use expect from chai. Chai is not included in a new Zuplo project, so install it yourself:

    TerminalCode
    npm install --save-dev chai @types/chai

    The CLI marks chai as external when it compiles your tests, so it resolves from node_modules at run time. Skip the install step and the compiler fails with Cannot find package 'chai' before any test runs.

    Prefer no dependencies? Node's built-in node:assert/strict works instead and needs nothing installed. The strict form compares with ===, so assert.equal(200, "200") fails instead of quietly passing.

  2. Write the first test

    Test files go in a tests folder at the root of your project and must end in .test.ts. Nested folders are fine.

    TypeScript/tests/health.test.ts
    import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; describe("gateway", () => { it("serves the root route", async () => { const response = await fetch(TestHelper.TEST_URL); expect(response.status).to.equal(200); }); });

    TestHelper.TEST_URL is whatever you passed to --endpoint. Build request URLs from it rather than hard-coding a host — that is the single change that lets one suite serve every environment:

    TypeScriptCode
    const url = (path: string) => new URL(path, TestHelper.TEST_URL).toString(); const response = await fetch(url("/v1/orders"));
  3. Run it

    Start the dev server in one terminal:

    TerminalCode
    npx zuplo dev

    Run the suite in another:

    TerminalCode
    npx zuplo test --endpoint http://localhost:9000

    The CLI discovers every tests/**/*.test.ts file, compiles them into .zuplo/__tests__, and runs them. .zuplo is generated output — leave it out of source control.

    Add the endpoint you use most to a script so the common case is one word:

    JSONpackage.json
    { "scripts": { "test": "zuplo test --endpoint http://localhost:9000" } }

TestHelper reference

TestHelper has two static members.

MemberTypeDescription
TestHelper.TEST_URLstringThe value passed to --endpoint. Throws if zuplo test was not given one.
TestHelper.environmentRecord<string, string>The test process environment. This is process.env — the two are interchangeable, including anything from .env.

TestHelper.environment reads the environment of the test process, not the environment variables configured on your Zuplo project. Those belong to the gateway; these belong to the test process. Fixture tokens, API keys, and seed data come in this way:

TerminalCode
TENANT_A_JWT=eyJhbGciOi... npx zuplo test --endpoint http://localhost:9000
TypeScript/tests/auth.test.ts
import { describe, it, TestHelper } from "@zuplo/test"; import { expect } from "chai"; describe("auth", () => { it("accepts a valid token", async () => { const response = await fetch(`${TestHelper.TEST_URL}/v1/orders`, { headers: { Authorization: `Bearer ${TestHelper.environment.TENANT_A_JWT}`, }, }); expect(response.status).to.equal(200); }); });

The CLI also loads a .env file from the directory you run it in, so local fixture values can live there instead of on the command line.

Never commit fixture credentials. Keep them in .env (gitignored) locally and in your CI provider's secret store in the pipeline.

Select which tests run

Two flags select tests by name. Both match against the full test name, which is the describe label and the it label joined together, so a test named smoke: orders route answers is selected by --filter smoke even when its enclosing describe does not match.

TerminalCode
# Only tests whose name contains "auth" npx zuplo test --endpoint http://localhost:9000 --filter "auth" # Regex form — wrap the pattern in slashes npx zuplo test --endpoint http://localhost:9000 --filter "/#label[Aa]/" # Everything except tests tagged [slow] in their name npx zuplo test --endpoint http://localhost:9000 --skip-filter "\[slow\]"

--skip-filter is applied after --filter, so the two compose. This is the mechanism behind production smoke checks: name the read-only subset consistently, then run only that subset against production.

Skip a test in code

Prefix a suite or test with .skip (or its alias .ignore) to declare it without running it:

TypeScript/tests/skip.test.ts
import { describe, it } from "@zuplo/test"; import { expect } from "chai"; describe("arithmetic", () => { it.skip("this test is declared but not run", () => { expect(1 + 4).to.equal(6); }); it("this test runs", () => { expect(1 + 4).to.equal(5); }); });

.only takes effect only when the run is started with --only. Without the flag, zuplo test runs the marked test and every other test — silently the opposite of what you wanted, and it looks like it worked.

TerminalCode
npx zuplo test --endpoint http://localhost:9000 --only

With the flag, only the marked tests and suites execute. If your CLI does not recognize --only, upgrade the zuplo package — or use --filter to narrow the run by test name, which works on every version.

Next steps

Last modified on