Running End-to-End Tests Against Preview Deployments

If your platform already builds a unique URL for every branch, you have the best end-to-end environment available: a real deployment, isolated per pull request, that nobody else is using. Most teams never point their suite at it, and instead keep fighting over one shared staging box or spend eight minutes booting the whole stack inside the CI job. This guide is for engineers on a platform that produces preview deployments — Vercel, Netlify, Cloudflare Pages, Render, a Kubernetes preview namespace, anything that yields a per-branch URL — running Playwright 1.4x in CI. It covers discovering the URL, waiting for the deployment to be genuinely ready, seeding data against an environment you do not control, and keeping the result trustworthy when the preview differs from production in ways that matter.

Root Cause Analysis

The alternatives fail for structural reasons, not for lack of effort. A shared staging environment is a single slot: two pull requests testing at once see each other’s data, a deploy in the middle of a run changes the application under test, and the suite’s redness becomes a function of who else is working. Booting the stack inside the CI job avoids that, but it tests a configuration nobody ships — a dev server with different bundling, different caching, sometimes a different database engine — and it pays the boot cost on every run.

A preview deployment is neither. It is built the way production is built, it exists for one branch, and it is already paid for because the platform builds it whether you test against it or not. The cost you do take on is a coordination problem: the deployment is asynchronous, so the test job must find its URL and wait for readiness, and it is remote, so data setup has to happen over the network rather than against a local database. Both are solvable, and solving them once removes an entire category of environment flakiness from the suite — the kind that flaky test mitigation can only contain rather than cure.

Three environment choices for an end-to-end run A shared staging environment is contended and drifts mid-run; a stack booted inside CI is isolated but not production-like; a per-branch preview deployment is both isolated and production-like at the cost of waiting for a deploy. Shared staging production-like: yes isolated: no drifts mid-run one slot, many branches Stack booted in CI production-like: no isolated: yes boots every run tests a config you never ship Preview deployment production-like: yes isolated: per branch must wait for deploy already built and paid for
The preview deployment trades one solvable coordination problem for two structural ones.

Reproducible Setup

The suite needs nothing platform-specific: it needs a base URL from the environment and a readiness probe. Keep the local default so developers can still run against localhost.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';

export default defineConfig({
  testDir: './e2e/specs',
  workers: process.env.CI ? 4 : undefined,
  // A preview host is remote, so allow a little more network latency.
  timeout: 45_000,
  expect: { timeout: 10_000 },
  use: { baseURL, trace: 'on-first-retry' },
});
// app/api/health/route.ts — the probe the CI job waits on
export async function GET() {
  const commit = process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.GIT_SHA ?? 'unknown';
  return Response.json({ status: 'ok', commit, migrated: true });
}

Returning the commit from the health endpoint is what makes readiness verifiable rather than hopeful: the job can confirm it is talking to this branch’s deployment and not a cached older one.

Implementation

Step 1 — Get the URL from the platform, not from a guess. Reconstructing a preview hostname from the branch name breaks on long names, slashes and forks. Every platform exposes the real URL, either as an action output or through its API.

# .github/workflows/e2e.yml
jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Wait for the preview deployment
        id: preview
        uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          max_timeout: 600
      - run: echo "preview at ${{ steps.preview.outputs.url }}"

Step 2 — Wait for readiness, not for the deployment event. A platform reports “deployed” when the build is uploaded; the application may still be cold, and migrations may still be running. Poll the health endpoint and check the commit matches.

#!/usr/bin/env bash
# scripts/wait-for-preview.sh <url> <expected-sha>
set -euo pipefail
url="$1"; sha="$2"; deadline=$(( $(date +%s) + 300 ))

while [ "$(date +%s)" -lt "$deadline" ]; do
  body=$(curl -fsS "$url/api/health" 2>/dev/null || true)
  if [ -n "$body" ] && [ "$(echo "$body" | jq -r .commit)" = "$sha" ]; then
    echo "ready: $url"
    exit 0
  fi
  sleep 5
done
echo "preview did not become ready in time" >&2
exit 1

Step 3 — Pass the URL into the suite as configuration. One environment variable, read in one place, is the whole integration. No test file should mention a hostname.

      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: ./scripts/wait-for-preview.sh "${{ steps.preview.outputs.url }}" "${{ github.sha }}"
      - run: npx playwright test
        env:
          E2E_BASE_URL: ${{ steps.preview.outputs.url }}
          E2E_ADMIN_TOKEN: ${{ secrets.E2E_ADMIN_TOKEN }}

Step 4 — Seed over the network, namespaced per run. A preview usually points at a shared database, so isolation comes from naming rather than from the environment. Reuse the per-worker namespacing described in isolating end-to-end tests with per-worker data, keyed on the run identifier so two concurrent pull requests cannot collide.

// e2e/fixtures/seed.ts
import { request } from '@playwright/test';

export const runPrefix = () => `pr-${process.env.GITHUB_RUN_ID ?? 'local'}`;

export async function seedOrg(name: string) {
  const api = await request.newContext({
    baseURL: process.env.E2E_BASE_URL,
    extraHTTPHeaders: { authorization: `Bearer ${process.env.E2E_ADMIN_TOKEN}` },
  });
  const res = await api.post('/api/admin/orgs', { data: { name: `${runPrefix()}-${name}` } });
  if (!res.ok()) throw new Error(`seed failed: ${res.status()} ${await res.text()}`);
  return res.json();
}

Step 5 — Handle deployment protection explicitly. Previews are often behind an authentication layer, which will greet Playwright with a login wall that has nothing to do with your application. Use the platform’s bypass mechanism rather than automating that wall.

// playwright.config.ts — a bypass header applied to every request
export default defineConfig({
  use: {
    baseURL: process.env.E2E_BASE_URL,
    extraHTTPHeaders: process.env.E2E_BYPASS_TOKEN
      ? { 'x-vercel-protection-bypass': process.env.E2E_BYPASS_TOKEN }
      : {},
  },
});
Pipeline order for a preview-targeted end-to-end run A push triggers the platform build; the test job resolves the preview URL, polls the health endpoint until the commit matches, seeds namespaced data, then runs the suite and uploads the trace. push branch updated platform build preview URL health poll commit matches seed data run-prefixed run suite Never start the suite before the commit on the URL is the commit under test a failure at the health poll is an infrastructure failure, reported separately from test failures
Separating the readiness failure from a test failure is what keeps the suite's signal clean.

Verification

The first thing to verify is that the run genuinely tested the branch. Print the resolved URL and the health response in the job log, so a green run carries its own evidence.

echo "base: $E2E_BASE_URL"
curl -s "$E2E_BASE_URL/api/health" | jq .
# { "status": "ok", "commit": "9f2c1ab…", "migrated": true }

Then confirm the suite fails for the right reason when the environment is wrong. Point it at a deliberately stale URL and check the failure is the readiness probe, not sixty confusing assertion errors.

E2E_BASE_URL=https://old-preview.example.app ./scripts/wait-for-preview.sh \
  "$E2E_BASE_URL" "$GITHUB_SHA"
# preview did not become ready in time   ← exits before the suite runs

Finally, run the same suite twice against the same preview. Identical results mean the environment is stable for the duration of a run; differing results mean something is redeploying underneath you, which is worth fixing before you trust any result from this pipeline.

Attributing a red run to the right cause A decision path: if the health probe failed the cause is infrastructure, if the commit did not match the run tested the wrong build, and only when both are sound is a failure attributable to the code under test. health probe failed infrastructure, not code commit mismatch tested the wrong build probe ok, sha ok a real test failure Log all three facts on every run so nobody has to guess which of the three they are looking at
Three logged facts turn "the pipeline is flaky" into an answerable question.

Troubleshooting

Symptom: tests run before the deployment is live and fail with connection errors. Diagnosis: the job waited on the platform’s deployment event rather than on application readiness. Fix: keep the health poll from Step 2 as a separate step with its own timeout, and require the commit to match so a previous deployment cannot satisfy the probe.

Symptom: everything 401s. Diagnosis: deployment protection is in front of the preview. Fix: supply the platform’s bypass token as an extraHTTPHeaders entry, as in Step 5. Do not automate the protection login — it changes without notice and is not the thing you are testing.

Symptom: two pull requests interfere despite separate preview URLs. Diagnosis: the previews share a database. Fix: namespace all seeded data by run identifier and filter every assertion by that namespace. The deployment is isolated; the data is not, and only naming makes it so.

Symptom: the suite is slower against a preview than locally. Diagnosis: real network latency on every action, which is normal. Fix: raise expect.timeout modestly rather than adding waits to tests, run more workers since the machine is no longer hosting the application too, and accept that a remote environment costs a little wall clock in exchange for testing what you ship.

FAQ

Should preview runs replace a nightly run against staging?

They complement it. Preview runs answer “does this change work”, which is what a merge gate needs. A nightly run against a longer-lived environment answers “does the system still work with realistic data volume, scheduled jobs and third-party integrations live”, which a fresh preview cannot. Keep both, but let the preview run be the one that gates merges.

What if the platform gives no readiness signal at all?

Add one to your application, as in the setup above. A health route that reports the commit and migration state costs a few lines, is useful in production monitoring anyway, and turns readiness from a guess into an assertion. Polling the homepage for a 200 is a weak substitute, because a cached edge response can answer before the application is genuinely up.

Can I run the whole matrix of browsers against a preview?

You can, but consider whether you should. The marginal defect rate from Firefox and WebKit on a per-pull-request run is low relative to the wall clock, so a common split is Chromium on every pull request and the full matrix nightly. This is the same cost-benefit reasoning as balancing speed and coverage in monorepo testing, applied to browser coverage.

How do I test a change that requires a database migration?

Make the health endpoint report migration state, as the setup does, and let the readiness probe wait on it. If your platform applies migrations out of band, the probe is the only thing standing between your suite and a run against a half-migrated schema — which produces failures that look exactly like application bugs and waste a great deal of time.