Appearance
In-Browser Testing
Run tests directly in the browser with a lightweight TestRunner class, HTML template fixtures, and method stubs. Then automate those same tests in CI by driving the browser with Playwright from Node.js.
When to use
Use in-browser tests when you want everything self-contained in a single file. This fits prototyping and authoring scenarios where you need verification that things work but want to avoid external test tooling. Instead of setting up a test runner, framework, or build step, you inline the tests directly into the document you're writing. The Playwright bridge is optional — add it later when the code graduates to CI.
The pattern
1. Browser-side test runner
A minimal TestRunner collects test functions, runs them sequentially, logs pass/fail with emoji prefixes, and renders results into the page. Tests use a tiny assert object so no libraries are needed.
html
<div id="test-output"></div>
<script>
class TestRunner {
constructor() {
this.tests = [];
this.results = [];
}
test(name, fn) {
this.tests.push({ name, fn });
}
async run() {
const output = document.getElementById("test-output");
output.innerHTML = "";
for (const { name, fn } of this.tests) {
const result = { name };
const start = performance.now();
try {
await fn();
result.status = "pass";
result.time = performance.now() - start;
console.log(`✅ ${name}`);
} catch (error) {
result.status = "fail";
result.error = error.message;
result.time = performance.now() - start;
console.error(`❌ ${name}:`, error);
}
this.results.push(result);
this.renderResult(result, output);
}
this.renderSummary();
}
renderResult(result, output) {
const div = document.createElement("div");
div.className = `test-result ${result.status}`;
const icon = result.status === "pass" ? "✅" : "❌";
div.innerHTML = `
<strong>${icon} ${result.name}</strong>
${result.error
? `<br><code>${result.error}</code>`
: ""}
<small>(${result.time.toFixed(2)}ms)</small>
`;
output.appendChild(div);
}
renderSummary() {
const passed = this.results.filter((r) => r.status === "pass").length;
const failed = this.results.filter((r) => r.status === "fail").length;
const total = this.results.length;
console.log(
`\n📊 Test Summary: ${passed}/${total} passed, ${failed} failed`,
);
}
}
const assert = {
equal(actual, expected, message) {
if (actual !== expected) {
throw new Error(message || `Expected ${expected}, got ${actual}`);
}
},
ok(value, message) {
if (!value) {
throw new Error(message || `Expected truthy value, got ${value}`);
}
},
throws(fn, message) {
try {
fn();
throw new Error(message || "Expected function to throw");
} catch {
// Expected
}
},
};
const runner = new TestRunner();
const test = (name, fn) => runner.test(name, fn);
</script>2. HTML template fixtures
Use <template> elements as test fixtures. Each template holds the DOM fragment a test needs. Clone the template content into a container before each test so every test starts with a clean DOM.
html
<template id="counter-test">
<button class="increment">+</button>
<span class="count">0</span>
</template>
<script>
function fixtureFromTemplate(selector) {
const tpl = document.querySelector(selector);
const container = document.createElement("div");
container.appendChild(tpl.content.cloneNode(true));
document.body.appendChild(container);
return container;
}
test("increment button updates count", () => {
const el = fixtureFromTemplate("#counter-test");
const btn = el.querySelector(".increment");
const count = el.querySelector(".count");
btn.click();
assert.equal(count.textContent, "1");
el.remove();
});
</script>3. Method stubs for event handlers
Assign plain functions to a container object so the template's event bindings resolve to your stubs. This avoids global pollution and gives each test its own handler scope.
javascript
test("event binding fires handler", async () => {
const container = document.createElement("div");
let clicks = 0;
let lastInput = "";
// Stubs — the template's on:click and on:input
// resolve against the container object
container.handleClick = () => { clicks++; };
container.handleInput = (e) => {
lastInput = e.target.value;
};
const render = bindTemplate("#event-binding-test", container);
render();
const btn = container.querySelector("button");
btn.click();
assert.equal(clicks, 1);
const input = container.querySelector("input");
input.value = "hello";
input.dispatchEvent(new Event("input"));
assert.equal(lastInput, "hello");
});4. Running in-browser tests from Node.js (CI)
Use Playwright to load the test HTML page, capture console messages, and translate the emoji-prefixed log lines into Node.js test assertions.
javascript
import { test, describe } from "node:test";
import assert from "node:assert";
import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { join, extname } from "node:path";
describe("in-browser tests", () => {
let server;
let browser;
let port;
test("setup", async () => {
server = createServer((req, res) => {
const file = req.url === "/" ?
"index.html" :
req.url.slice(1).split("?")[0];
const ext = extname(file);
const types = {
".js": "application/javascript",
".html": "text/html",
".css": "text/css",
};
try {
const body = readFileSync(join(import.meta.dirname, file));
res.writeHead(200, { "Content-Type": types[ext] || "text/plain" });
res.end(body);
} catch {
res.writeHead(404);
res.end("Not found");
}
});
port = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
resolve(server.address().port);
});
});
browser = await chromium.launch({ headless: true });
});
test("run tests", async () => {
const page = await browser.newPage();
const messages = [];
page.on("console", (msg) => {
const text = msg.text();
messages.push(text);
console.log(text);
});
await page.goto(`http://localhost:${port}/tests.html`,);
await page.waitForTimeout(1000);
const results = [];
for (const msg of messages) {
if (msg.startsWith("✅")) {
results.push({
name: msg.replace("✅ ", ""),
status: "pass",
});
} else if (msg.startsWith("❌")) {
const [head, ...rest] = msg.split(":");
results.push({
name: head.replace("❌ ", ""),
status: "fail",
error: rest.join(":").trim(),
});
}
}
const failed = results.filter((r) => r.status === "fail");
if (failed.length > 0) {
const lines = failed.map((t) => `${t.name}: ${t.error}`);
throw new Error(`${failed.length} failed:\n${lines.join("\n")}`);
}
assert.ok(results.length > 0, "Should have test results");
await page.close();
});
test("cleanup", async () => {
if (browser) await browser.close();
if (server) {
await new Promise((resolve) => server.close(resolve));
}
});
});Trade-offs
| Approach | Pro | Con |
|---|---|---|
| In-browser runner | Real DOM, zero build | Manual or CI harness |
| Playwright bridge | Automated CI | Slower than Node.js |
| jsdom / happy-dom | Fast, no browser | Missing APIs |
Use the in-browser runner for DOM-heavy code where fidelity matters. Fall back to jsdom only for logic that touches trivial DOM surface area.