Testing in a Headless Browser

"Headless statue closeup"

Headless Statue Closeup, Chris Lexow on Flickr

For over 12 years, I’ve been developing web-based data visualizations, and automating tests in a browser environment has always been a challenge. It’s possible, but often painful and hard to maintain. Moreover, automating benchmarks in a browser environment has been even more elusive.

In this blog post, I’ll demonstrate how easy it is to set up Vite, Vitest, and Playwright to test and benchmark TypeScript code in a headless browser. This setup allows tests and benchmarks to be run from the command line and in CI, simplifying a historically complex configuration.

My Journey to Vitest

While recently working on sigma.js v3, I started to modernize all the tooling. The existing setup consisted of outdated Webpack configurations, ad-hoc scripts, and extensive boilerplate. It was challenging to maintain and evolve, with several unresolved issues, including running unit tests in a browser environment.

I spent hours searching for a modern, easy-to-set-up testing solution, to no avail. Various blog posts suggested solutions based on Puppeteer, PhantomJS, or even CasperJS, but they all seemed outdated and/or difficult to set up (and likely maintain).

Vitest for Unit Testing

Eventually, I stumbled upon Vitest Browser Mode based on Playwright, which perfectly fit my requirements. At that point, sigma.js was already depending on Playwright for end-to-end testing. I wish this solution had been easier to find, and I hope this post accelerates its discovery for others.

Vitest for Benchmarking

During my research, I also discovered Vitest’s Benchmarking feature, powered by TinyBench. It enables Vitest to run TinyBench for executing benchmarks. It looked like “just a shortcut”. However, when combined with the Browser Mode, it became significantly more powerful: It enables benchmarking TypeScript code from the command line in a browser environment.

However, integrating these features was initially not possible due to an issue in Vitest. The Vitest team resolved the ticket in a few months, and with a clear communication on the progress, which was great to witness.

"A doodle done with the sample project"
A doodle done with the sample project

Testing And Benchmarking A Sample Project

To demonstrate how easily Vitest and Playwright can be used to test and benchmark TypeScript code using a headless browser, I created a sample project. It aims to compare two methods of drawing thick lines on a canvas element: using CanvasRenderingContext2D.drawRect or using CanvasRenderingContext2D.stroke (with a thick lineWidth).

The project is available here: github.com/jacomyal/canvas-benchmark

The Main Code

Essentially, the two functions I tested and benchmarked look like this:

function fillBasedThickLine(ctx, from, to, thickness, color) {
  const dx = to.x - from.x;
  const dy = to.y - from.y;
  const d = Math.sqrt(dx ** 2 + dy ** 2);
  const angle = Math.atan2(dy, dx);

  ctx.save();
  ctx.fillStyle = color;
  ctx.translate(from.x, from.y);
  ctx.rotate(angle);
  ctx.fillRect(0, 0 - thickness / 2, d, thickness);
  ctx.restore();
}

function strokeBasedThickLine(ctx, from, to, thickness, color) {
  ctx.beginPath();
  ctx.moveTo(from.x, from.y);
  ctx.lineTo(to.x, to.y);

  ctx.lineWidth = thickness;
  ctx.strokeStyle = color;
  ctx.stroke();
}

The exact code is available in the src/index.ts file. To test that they function correctly, I added a small example, where users can click on a canvas to draw thick lines. You can play with it here:

Unit Testing

I began by writing a test for each function to verify that the function colors at least one pixel on a 1x1 canvas. They look like that:

test("it should colorize pixels", () => {
  // Draw a thick line that contains the only pixel
  drawThickLine(ctx, { x: -1, y: -1 }, { x: 1, y: 1 }, 2, "#ff0000");

  // Retrieve the pixel color:
  const {
    data: [r, g, b, a],
  } = ctx.getImageData(0, 0, 1, 1);

  expect([r, g, b, a]).toStrictEqual([255, 0, 0, 255]);
});

The full version with the boilerplate is available in the src/index.spec.ts file.

To run the unit tests, I first installed Vitest and Playwright:

npm install --save-dev vitest @vitest/browser playwright

Playwright also needs to install its headless browsers locally:

npx playwright install

Then, I added a vitest.config.mts file, instructing Vitest to use Playwright and the Browser Mode:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["*.ts"],
    dir: "src",
    browser: {
      provider: "playwright",
      name: "chromium",
      enabled: true,
      headless: true,
    },
  },
});

At this point, when I ran npx vitest test src/index.spec.ts, it logged the following:

DEV  v1.5.3 canvas-benchmark
Browser runner started at http://localhost:5173/

âś“ src/index.spec.ts (2)
âś“ fillBasedRectangle (1)
âś“ it should colorize pixels
âś“ strokeBasedRectangle (1)


âś“ it should colorize pixels

Test Files  1 passed (1)
Tests  2 passed (2)
Start at  16:58:52
Duration  800ms (transform 0ms, setup 0ms, collect 20ms, tests 6ms, environment 0ms, prepare 0ms)

Unit tests ran successfully from the command line in a headless browser!

Benchmarking

To benchmark those functions, I wrote something like this:

bench(
  "Canvas methods to draw a thick line",
  () => {
    const angle = 2 * Math.PI * Math.random();
    drawThickLine(
      ctx,
      {
        x: 500 - 500 * Math.cos(angle),
        y: 500 - 500 * Math.sin(angle),
      },
      {
        x: 500 + 500 * Math.cos(angle),
        y: 500 + 500 * Math.sin(angle),
      },
      50,
      getRandomColor(),
    );
  },
  { iterations: 1000 },
);

Again, the full version with the boilerplate is available in the src/index.bench.ts file.

To enable the Benchmarking feature, I added one line to the Vitest config:

export default defineConfig({
  mode: "benchmark",
  test: {
    /* ... */
  },
});

Now, running npx vitest bench ./src/index.bench.ts logs the following output:

DEV  v1.5.3 canvas-benchmark
Browser runner started at http://localhost:5173/

âś“ src/index.bench.ts (2) 8624ms
âś“ Canvas methods to draw a thick line (2) 8615ms
name                             hz     min       max    mean     p75     p99    p995    p999       rme  samples
· Using a filled rectangle  15,341.30  0.0000  1,785.50  0.0652  0.0000  0.1000  0.1000  0.1000  ±184.32%    29127   fastest
· Using a wide stroke       14,882.96  0.0000  2,427.90  0.0672  0.0000  0.1000  0.1000  0.1000  ±189.11%    37450

BENCH  Summary

Using a filled rectangle - src/index.bench.ts > Canvas methods to draw a thick line
1.03x faster than Using a wide stroke

Both methods exhibit very similar performance. And this insight is automatisable, and reproducible in the command line.

Conclusion

I’ve been developing JavaScript libraries targeting the browser for over 10 years, and proper unit testing has always been challenging. Benchmarking was merely a dream. Now, thanks to Vitest and Playwright, integrating these processes into any TypeScript project has become remarkably easy.