Skip to content

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

ApproachProCon
In-browser runnerReal DOM, zero buildManual or CI harness
Playwright bridgeAutomated CISlower than Node.js
jsdom / happy-domFast, no browserMissing 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.