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.
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 }
: {},
},
});
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.
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.
Related
- Back to End-to-End Test Architecture
- Isolating end-to-end tests with per-worker data — the naming discipline a shared preview database needs.
- Gating merges with required status checks — make the preview run block the merge.
- Caching dependencies and test artifacts in CI — cut the setup time around the run.