Appearance
Playwright Browser Testing
Set up Playwright with Chromium for end-to-end browser testing in local development and GitHub Actions CI.
When to use
Use Playwright when tests need a real browser — navigation, clicks, form fills, network interception, screenshots. It runs headless by default, making it suitable for both local npm test and CI pipelines.
The pattern
Install
Two options depending on context:
Global install via Volta — best for ad-hoc browser automation, quick scripting, and one-off testing outside a project:
sh
volta install playwright
playwright install chromiumThis puts playwright on your PATH everywhere without touching any project's package.json.
Local install per project — best when the project has Playwright tests, especially if they run in CI. CI environments don't have Volta, so the project needs Playwright in its own dependencies:
sh
npm install --save-dev playwright
npx playwright install chromiumInstall only Chromium unless you need cross-browser coverage — this keeps browser downloads small.
Write a test
Use node:test with Playwright's chromium launcher. Each describe block manages its own browser lifecycle through setup and cleanup tests:
javascript
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { chromium } from "playwright";
describe("home page", () => {
let browser;
let page;
test("setup", async () => {
browser = await chromium.launch({
headless: true,
});
page = await browser.newPage();
});
test("loads and shows heading", async () => {
await page.goto("http://localhost:3000");
const heading = await page.textContent("h1");
assert.strictEqual(heading, "Welcome");
});
test("navigates to about page", async () => {
await page.click('a[href="/about"]');
await page.waitForURL("**/about");
const title = await page.title();
assert.ok(title.includes("About"));
});
test("cleanup", async () => {
await browser?.close();
});
});Run with node --test test/browser.test.js.
Console message capture
Intercept browser console output to verify client-side logging or detect errors:
javascript
const messages = [];
page.on("console", (msg) => {
messages.push(msg.text());
});
page.on("pageerror", (error) => {
console.error("Page error:", error.message);
});
await page.goto("http://localhost:3000");
await page.waitForTimeout(1000);
assert.ok(
messages.some((m) => m.includes("ready")),
"Expected 'ready' in console output",
);GitHub Actions CI
Add a workflow step that installs only Chromium before running tests:
yaml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Install Playwright Browsers
run: npx playwright install chromium
- run: npm testServing files during tests
When testing static HTML, spin up a temporary server with automatic port selection so tests don't collide:
javascript
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { join, extname } from "node:path";
function startServer(root) {
const types = {
".js": "application/javascript",
".html": "text/html",
".css": "text/css",
};
const server = createServer((req, res) => {
const file = req.url === "/"
? "index.html"
: req.url.slice(1).split("?")[0];
try {
const body = readFileSync(
join(root, file),
);
res.writeHead(200, {
"Content-Type":
types[extname(file)] || "text/plain",
});
res.end(body);
} catch {
res.writeHead(404);
res.end("Not found");
}
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
resolve({ server, port });
});
});
}Trade-offs
| Choice | Pro | Con |
|---|---|---|
| Chromium only | Fast install, small CI cache | No Firefox/WebKit coverage |
| All browsers | Cross-browser confidence | Slower CI, larger downloads |
| Headless | CI-friendly, faster | Can't visually debug |
| Headed | See what's happening | Can't run in CI |
Start with Chromium-only headless. Add Firefox or WebKit only after you've hit a browser-specific bug that warrants the extra CI time.