Playwright Smoke Test in GitHub Actions: Serve, Wait, Fail
Add a Playwright smoke test to GitHub Actions: serve the built Next.js app, wait until it answers, install headless Chromium and print the log on failure.
By Matthews WongReviewed on
Key takeaway
A smoke test opens the built app in a real browser and checks that the few things that must work, do. It catches what unit and contract tests cannot: a build that compiles but renders a broken page. In CI you build, start the server in the background, wait for it to answer, run the test, and print the server log if anything fails.
You will be able to
- Explain what a browser smoke test guards that unit and contract tests cannot.
- Write a workflow that builds, serves, waits for readiness, tests and shows the server log on failure.
- Keep the Playwright version and its browser build in step.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
From build to a failing smoke test
A workflow builds the app, starts it in the background with its output in a log file, polls until it answers, runs the browser test, and prints the log when the test fails.
Keys: ← → step · Home / End · K play or pause
One job runs five things in order: the build, a background server, a wait loop, a headless browser test, and a log file that is only read if something fails.
The build runs first, then the server starts in the background with its output sent to a log file, so the next step can run while it stays up.
PORT=3000 npm start > server.log 2>&1 &Starting is not ready. A loop requests the page every two seconds and moves on only when it gets a success response.
if curl -sf -o /dev/null http://localhost:3000/en/play/; then exit 0; fiPredict the next step The first request arrives before the server is listening. What should the loop do? (Fail the job at once · Sleep, then try again · Start the tests anyway)With the server answering, a script opens the page in headless Chromium, clicks through the key path and records every check that does not hold.
BASE_URL=http://localhost:3000 npm run game:smokePredict the next step A check fails, say the canvas never appears. What does the script do? (Passes anyway · Prints FAIL and exits with code 1 · Stops the server)The script sets a non-zero exit code. The workflow step fails, and later steps that only run on success are skipped.
process.exitCode = 1;A final step marked to run on failure prints the log file. The browser says what broke on screen, and the log says why the server broke.
if: failure()
What does a smoke test guard that unit tests cannot?
A smoke test answers one question: does the thing we are about to ship basically work? It opens the real built app in a real browser and exercises the shortest path through it. Unit and contract tests read data and call functions, so they pass even when the bundle throws on load, a route returns an error page, or a script fails in the browser. Only a run of the built output notices that.
Read more (2)Show less
This repo's smoke script, written for its 3D game page, shows the shape. It checks that the consent notice renders, that no heavy asset files are requested before the visitor accepts, that accepting mounts a canvas that actually produces frames, that a deep link opens the right card, and that the page logged no console errors. Those are user-visible outcomes, which is what the Playwright docs say tests should verify.
Keep it small. A smoke test is a tripwire, not a regression suite: a few checks on the path that matters most, so a failure means something is clearly broken and the run stays short enough to run on every pull request.
How do you start the built app in a workflow and wait for it?
A CI step is a shell command, and a server never exits, so you start it in the background with a trailing ampersand and send its output to a file. Then you wait. Starting a process is not the same as it being ready, so the next step must not assume the port is open. This workflow polls the real page with curl every two seconds, up to sixty times, and treats the first success as ready.
Read more (2)Show less
Polling a real page beats sleeping a fixed time: it continues as soon as the server is ready and never guesses. If the loop runs out, the step prints a message, dumps the server log and exits 1, so a server that never came up fails with its own error message in view instead of a vague timeout in the test step after it.
Why does a failing command stop the job at all? GitHub documents that by default the bash shell for a step runs with fail-fast behaviour, so a non-zero exit code fails the step, and a failed step skips the ones after it unless they say otherwise.
How do you install headless Chromium and keep versions in step?
Playwright downloads its own browser builds. The docs show npx playwright install --with-deps chromium for installing one browser together with the operating system libraries it needs on Linux, and advise installing only the browsers you use, especially on CI. By default Playwright launches browsers headless, so a plain ubuntu runner needs no display.
Read more (3)Show less
The docs also state that each version of Playwright needs specific versions of browser binaries. That is the pinning rule: the browser build is a function of the Playwright package version, so install the browser after the dependencies, from the same lockfile, in the same job. This repo's package.json allows a range, while the lockfile records the exact release the workflow actually gets, and a changed version means a different browser build to download.
What about caching the browser? The CI page of the docs advises against it, saying restoring the cache takes comparable time to a download, and that the Linux system dependencies are not cacheable anyway. If you cache regardless, key the cache on the Playwright version. This workflow caches the npm packages through setup-node and downloads Chromium fresh on each run.
One practical note for 3D pages: with no GPU on the runner, the script launches Chromium with flags that select a software renderer, so WebGL works. That is specific to this game, and a normal page needs no extra flags.
How do you make a failed run explain itself?
The server ran in the background, so its output went to a file and a failed test shows only the browser side. A step with if: failure() fixes that. GitHub documents that failure() is true when any previous step of the job failed, and that a step runs only after success unless you add a status check function to its condition. The last step of this workflow uses it to print the log.
Read more (2)Show less
The test script cooperates. Instead of throwing at the first problem, it records each failure with a clear message, sets the exit code to 1, and carries on, so one run reports every broken check. For failures you cannot reproduce from text, the Playwright docs recommend the trace viewer on CI over videos and screenshots, which you can add on top of the log.
Finally, fence the job in. The workflow cancels an older run of the same branch when a new push arrives, grants only read access to the repository contents, and runs only when relevant paths change. A smoke test that nobody waits for is a smoke test people keep.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
- name: Serve
run: |
PORT=3000 npm start > server.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:3000/en/play/; then exit 0; fi
sleep 2
done
echo "server did not come up"
cat server.log
exit 1Start, wait, give up loudly. The trailing ampersand backgrounds the server and 2>&1 sends both streams to server.log. The loop returns on the first successful request, and if sixty tries pass, it prints the log and exits 1 so the job fails here, not later.
- name: Server log on failure
if: failure()
run: cat server.log || trueThe safety net. failure() overrides the default success-only condition, so this runs after any earlier step failed. The trailing || true keeps the step itself from failing if the log file does not exist yet.
- name: Install Chromium for Playwright
run: npx playwright install --with-deps chromiumOne browser plus its operating system libraries, installed after npm ci so the browser build matches the installed Playwright version. Only Chromium is installed because the test only launches Chromium.
function fail(message) {
console.error(`smoke-test: FAIL — ${message}`);
process.exitCode = 1;
}A check that fails without stopping the run. It prints a labelled message and sets the exit code, so every broken check shows up in one pass and the process still ends with a failing status that the workflow step understands.
const browser = await chromium.launch({
headless: true,
args: [
"--use-gl=angle",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
],
});Headless Chromium with a software GL renderer, because a CI runner has no GPU and this page draws WebGL. The args are for this 3D game only; an ordinary page would call launch with headless alone.
"playwright": "^1.60.0",The dependency allows a range. The package-lock.json beside it records the exact release that gets installed, and the browser build to download follows that version. Tighten the range if you need the same browser build on every machine.
Mistakes that cost real time
Tests start before the server is ready
Launching the server and running the test in consecutive lines races the port opening, and the first run passes or fails by luck. Poll a real URL until it answers, and fail the wait step with the log if it never does.
Browser build missing after a version bump
Each Playwright version needs specific browser binaries. If a dependency range moves Playwright to a release whose browser build is not on the machine, launching fails until you run the install command again. In CI the install step runs every time, which is why it works there.
A failure with no server log
The server's output went to a file, so without a step that prints it a red run says only that a browser check failed. Add a step with if: failure() that prints the log.
A smoke test that grows into a flaky suite
Every extra path adds more ways to fail for reasons unrelated to the change. Keep the smoke test to the shortest path that proves the app is alive, and test depth elsewhere. Stub third-party responses so an outside site cannot turn the run red.
| Aspect | Unit or contract test | Browser smoke test |
|---|---|---|
| Runs against | Source modules and data | The built, served app |
| Catches | Wrong logic, broken data rules | A page that errors, a script that throws, a missing asset |
| Needs | Node only | A build, a running server and a browser |
Do it on your own machine
Add a smoke test to a Next.js project and run it the way CI would, on your own machine first.
Install Playwright as a dependency and download only Chromium with its system libraries.
npm install --save-dev playwright npx playwright install --with-deps chromiumWrite scripts/smoke.mjs. Open the home page, assert one visible heading, and set process.exitCode = 1 with a clear message when it is missing.
import { chromium } from "playwright"; const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); await page.goto(process.env.BASE_URL ?? "http://localhost:3000"); if (!(await page.getByRole("heading", { level: 1 }).isVisible())) { console.error("smoke: FAIL - no h1"); process.exitCode = 1; } await browser.close();Build, start the server in the background with its output in a file, and wait with a polling loop before you run the script.
npm run build PORT=3000 npm start > server.log 2>&1 & for i in $(seq 1 30); do curl -sf -o /dev/null http://localhost:3000/ && break; sleep 1; done node scripts/smoke.mjsCopy the same commands into a workflow. Add a last step with if: failure() that runs cat server.log, then break the page on purpose and read the log in the run.
- name: Server log on failure if: failure() run: cat server.log || true
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.Why install the browser after the npm dependencies, in the same job?Show the answer
Answer:Each Playwright version needs specific browser binaries. Installing after the dependencies means the install command reads the version that is actually installed, so the browser build matches it.
2.The smoke script records a failure and keeps going instead of throwing. What is the benefit?Show the answer
Answer:One run reports every broken check, and the exit code is still non-zero so the workflow step fails. You fix several problems per push instead of one.
3.Should you cache the Playwright browser in CI?Show the answer
Answer:The Playwright CI docs advise against it, because restoring the cache takes comparable time to downloading, and system dependencies are not cacheable. If you do, key it on the Playwright version.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.