zudo-test-wisdom
GitHub repository

Type to search...

to open search from anywhere

Environment-Tiered Testing Against Deployed Targets

One contract suite pointed at local, a preview deploy, and production -- what belongs in each tier, how to reach gated previews, and why a prod failure is an incident, not a test run.

Most of this guide tests code before it ships: unit suites, build-output reads, a browser driven against a local dev server. A separate layer tests the thing that actually shipped -- the same suite pointed at a deployed URL. Three portfolio repos run this as a first-class lane: zmod and zzmod fire their preorder-API contract suite at a local dev server, at a preview deploy, and at production; takazudo-auth drives logged-in flows against the same tiers. This page is about running one suite across many environments without forking your test command three ways -- and without ever mutating production.

One Suite, Three Targets

The knob is a single environment variable. The suite resolves its base URL from API_BASE_URL, falling back to the local dev server, and a separate TEST_TIER label decides only whether destructive fixtures are allowed to run -- never what the read-path assertions check.

// test/contract/env.ts -- one contract suite, three deploy targets
export const API_BASE_URL =
  process.env.API_BASE_URL ?? "http://localhost:9999"; // dev server default

// The tier label gates mutation; it never changes what the assertions check,
// only whether the suite is allowed to write.
export const TIER = (process.env.TEST_TIER ?? "local") as
  | "local"
  | "preview"
  | "production";

export const MUTATION_ALLOWED = TIER === "local";

The contract tests import that base URL and hit the real endpoint. Read-path assertions run against every tier; write fixtures are fenced behind MUTATION_ALLOWED so the identical file is safe to point at production.

// test/contract/preorder.contract.test.ts
import { describe, it, expect } from "vitest";
import { API_BASE_URL, MUTATION_ALLOWED } from "./env.js";

describe(`preorder API @ ${API_BASE_URL}`, () => {
  // read-path contract: runs against every tier, mutates nothing
  it("rejects an unknown SKU with a 404 and a typed error body", async () => {
    const res = await fetch(`${API_BASE_URL}/api/preorders/does-not-exist`);
    expect(res.status).toBe(404);
    expect(await res.json()).toMatchObject({ error: "not_found" });
  });

  // destructive fixture: local only -- never touches a deployed target
  it.runIf(MUTATION_ALLOWED)("creates then cancels a preorder", async () => {
    const created = await fetch(`${API_BASE_URL}/api/preorders`, {
      method: "POST",
      body: JSON.stringify({ sku: "TEST-SKU", qty: 1 }),
    });
    expect(created.status).toBe(201);
    const { id } = await created.json();
    const cancelled = await fetch(`${API_BASE_URL}/api/preorders/${id}`, {
      method: "DELETE",
    });
    expect(cancelled.status).toBe(204);
  });
});

Wiring the tiers is three scripts over one suite. The preview URL is not hardcoded -- CI injects the deploy URL it just built ($DEPLOY_PRIME_URL on Netlify, the preview URL from wrangler deploy output on Cloudflare), so the suite always points at the deploy under test.

{
  "scripts": {
    "test:contract:local": "API_BASE_URL=http://localhost:9999 TEST_TIER=local vitest run test/contract",
    "test:contract:preview": "API_BASE_URL=$DEPLOY_PRIME_URL TEST_TIER=preview vitest run test/contract",
    "test:contract:prod": "API_BASE_URL=https://api.zzmod.example TEST_TIER=production vitest run test/contract"
  }
}

Note

The assertions are byte-identical across tiers; only the base URL and the mutation gate change. If a preview needs a different read-path assertion than local, that gap is exactly the config drift you want the suite to catch -- resist the urge to special-case it away with a per-tier branch.

What Belongs in Each Tier

Each environment earns a different slice of the suite. The rule is a one-way ratchet: the further from your laptop, the less you are allowed to mutate.

TierWhat runsMay mutate?
LocalFull CRUD, destructive fixtures, seed-and-teardown, error-injectionYes -- it is your machine's data
PreviewContract shape + read-path assertions; auth handshake; no writes to shared stateRead-only against shared state
ProductionStrictly read-only smoke -- health, a known-good read, a signed-in GETNever

Local is where the expensive, stateful, destructive work lives -- it owns its own database, so it can create a preorder, cancel it, corrupt a row on purpose, and reset. Preview verifies the contract survived the deploy: the routes resolve, the shapes match, auth still hands back a token. Production runs the thinnest possible read-only pass, because production data belongs to real users.

Danger

Never mutate production from a test. A POST/PUT/DELETE fixture that is harmless locally becomes a live order, a real charge, or a deleted account when API_BASE_URL points at prod. The MUTATION_ALLOWED gate above is not a convenience -- it is the one line standing between your suite and a money-touching side effect. For a money API like preorders, treat any prod-tier write as a production incident in code review, the same as a stray DROP TABLE.

Reaching a Gated Preview

Preview deploys are usually behind a password wall (Netlify password protection, Cloudflare Access). The whole deploy sits behind one gate cookie, so an automated run has to unlock it before it can see a single route. Trade the gate password -- a CI secret, never a literal -- for that cookie once, then replay the cookie on every request. zzmod documents this specifically so AI-agent runs can reach a protected preview the same way CI does.

# A password-protected preview puts the WHOLE deploy behind one gate cookie.
# Trade the gate password (a CI secret) for that cookie once, then replay it --
# automated runs never render the HTML password wall.
# Cookie name is provider-specific (Netlify: nf_jwt; CF Access: CF_Authorization).
COOKIE=$(curl -sS -i -X POST "$PREVIEW_URL/" \
  --data-urlencode "password=$PREVIEW_GATE_PASSWORD" \
  | sed -n 's/^[Ss]et-[Cc]ookie: \([^;]*\).*/\1/p')

curl -sS -H "Cookie: $COOKIE" "$PREVIEW_URL/api/health"   # now reaches the app

For a Playwright run against the same gated preview, the cookie goes on the browser context instead of a header -- inject it once in global setup and every page in the run inherits it, which is the same mechanism used for logged-in flows below.

Logged-In Flows Without Leaking Secrets

Passing the deploy gate only gets you to the login screen. Flows that need an authenticated user -- takazudo-auth's whole surface is session-gated -- log in once, persist the session with Playwright's storageState, and reuse it across the run. The credentials come from the environment; they never appear in a spec.

// e2e/auth.setup.ts -- log in once, persist the session, keep creds out of specs
import { test as setup, expect } from "@playwright/test";

const authFile = "e2e/.auth/user.json"; // gitignored -- holds a live session

setup("authenticate", async ({ page }) => {
  const email = process.env.E2E_USER_EMAIL; // from CI secrets, never inline
  const password = process.env.E2E_USER_PASSWORD;
  if (!email || !password) throw new Error("E2E auth creds not set");

  await page.goto("/login");
  await page.fill("[name=email]", email);
  await page.fill("[name=password]", password);
  await page.click("button[type=submit]");
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();

  await page.context().storageState({ path: authFile });
});

The config runs that setup as a dependency, then feeds its saved state into the real project so every test starts already signed in:

// playwright.config.ts (excerpt)
export default defineConfig({
  projects: [
    { name: "setup", testMatch: /auth\.setup\.ts/ },
    {
      name: "authenticated",
      dependencies: ["setup"],
      use: { storageState: "e2e/.auth/user.json" },
    },
  ],
});

The saved state file is a live bearer credential, so it is treated like one -- gitignored, regenerated per run, and scoped to a throwaway test account, never a real user's:

# .gitignore -- the session file is a live credential, never commit it
e2e/.auth/

Warning

storageState is a bearer of a real session -- anyone with the file is logged in. Keep it out of git, out of artifacts, and out of any spec as a literal. This weight is also why logged-in E2E is the classic "too environment-heavy for PR CI" case: it needs live creds and a reachable deploy, so it runs post-deploy or on a schedule, not on every PR.

Post-Deploy Smoke as the Final Tier

The last tier is a cheap, fast, alarm-only pass that runs the moment a deploy goes live: hit a handful of real endpoints, assert they answer, page a human if they do not. It is deliberately read-only and shallow -- it catches misconfigured secrets, missing migrations, and binding errors that no pre-deploy test can see, not logic bugs. The mechanics (a self-cleaning shell script, the trap EXIT cleanup, the local-first / remote-after wiring) are already covered in Backend & Node.js Testing § Post-Deploy Smoke Testing; the environment-tiered view just adds where it sits -- production, read-only, alarm on red.

Failure Semantics per Environment

The same red X means something different at each tier, and that difference decides who gets woken up. Route the lanes accordingly using the execution-tiers vocabulary.

EnvironmentA failure meansRuns atTier
LocalYour bug -- the code under the current diff is wrongInner loop + PR gateT0 / T1
PreviewIntegration or config drift -- the code is fine, the wiring around it is notPost-deploy on the preview URLT1 (post-deploy) / T3
ProductionAn incident -- real users are affected right nowPost-deploy smoke + scheduleT3

A local contract failure just fails your PR; you fix it before merge and nobody else notices. A preview failure means the code merged clean but the deployed wiring -- an env var, a secret, a binding, a CORS rule -- drifted, so the fix is in configuration, not the diff. A production failure is not a failing test at all: it is an alert that something users depend on is down, and it belongs to whoever is on call, not to the person who last touched the suite. Same assertions, three blast radii -- which is the whole reason to keep them on separate lanes with separate alerting rather than one vitest run that treats prod like a bigger localhost.

Where to Go Next

  • Backend & Node.js Testing -- the HTTP-API base-URL switch, destructive-test guards, and the post-deploy smoke script this page builds on

  • Execution Tiers -- the T0-T4 vocabulary that decides where each environment lane runs

  • Playwright Patterns -- CI-safe vs environment-heavy test splitting, the mock-backend adapter, and console-error monitoring for these live runs

  • Scheduled Re-exam & Night Exam -- running the heaviest environment-bound lanes on a schedule with deduped failure issue filing

Revision History

CreatedUpdated