Command Palette

Search for a command to run...

Hectal
PHASE 16Advanced ~66 min· topic 7 of 8

Topic 16.7

Testing Charts

In one line

Most of a chart's logic is plain functions (scales, shaping, rules) that a test can check in milliseconds. Test those first, check the marks in the DOM second, pin time zones, and treat screenshots as a last, small layer.

Think of it like this

A baker's checks. She weighs the flour (a number: easy to test), tastes the dough (a quick check on the middle step), and only at the end looks at the finished loaf (slow, subjective). Checking only the loaf means she finds out about the flour too late.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

unit test
A test of one small function: inputs in, output checked.
assertion
One check inside a test, such as expect(x(0)).toBe(0).
Vitest
A test runner for Vite projects. vitest run runs every *.test.js file once.
jsdom
A fake browser page made in Node, so DOM code can be tested without a real browser.
flaky
A test that sometimes passes and sometimes fails with no code change.
visual regression
Comparing a new screenshot of a chart with a saved one, and failing on a difference.

Step by step

01Write the plain tests

The first example is a 20-line test runner (check and eq) and thirteen real checks of the code from this phase: a scale, the percent change, the age words, the CSV rules and the live-feed rules. One check is wrong on purpose, so you see what a failure prints.

02Check the marks and the zones

The second example draws a bar chart, then asserts on the DOM: seven bars, the tallest is Tuesday, heights are in proportion, no NaN in any attribute, and an empty list gives the empty message and no bars. It ends with three time-zone checks written against the Azores.

Check the marks and the zonesdiagram
Rendering diagram…

03Run Vitest in the project

Install Vitest, add the script, create tests/scales.test.js (below) and run npm test. The terminal blocks show the real output, then the same run with the time zone changed, then a failing test.

terminal
$ npm install -D vitest
npm test
── expected output ──
RUN v5.0.5
 
✓ tests/scales.test.js > scales > maps the ends and the middle of the domain
✓ tests/scales.test.js > scales > nice() rounds the domain outwards to round numbers
✓ tests/scales.test.js > scales > returns the middle of the range when every value is equal
✓ tests/scales.test.js > scales > gives undefined, not a number, for missing input
…
✓ tests/scales.test.js > retries > does not retry a 404
 
Test Files 1 passed (1)
Tests 15 passed (15)
Captured with Vitest 5.0.5 and D3 7.9.0 on 16 October 2026, with the reporter set to verbose (npx vitest run --reporter=verbose). The default reporter prints a shorter summary.

04Change the time zone, then break a test

The same suite ran with TZ=Pacific/Auckland and TZ=Atlantic/Azores: 15 passed in both, because the code uses UTC. A changed expectation (toBe(300) instead of 280) fails like this.

terminal
$ npx vitest run # after changing one expectation from toBe(280) to toBe(300)
── expected output ──
FAIL tests/scales.test.js > scales > maps the ends and the middle of the domain
AssertionError: expected 280 to be 300 // Object.is equality
 
- Expected
+ Received
 
- 300
+ 280
 
❯ tests/scales.test.js:15:21
13| it("maps the ends and the middle of the domain", () => {
14| expect(x(0)).toBe(0);
15| expect(x(2190)).toBe(300);
| ^
16| expect(x(4380)).toBe(560);
 
Test Files 1 failed (1)
Tests 1 failed | 14 passed (15)
To run in another zone: TZ=Pacific/Auckland npx vitest run on macOS and Linux, or $env:TZ='Pacific/Auckland'; npx vitest run in PowerShell. Both zones printed 15 passed.

Try it yourself

  1. 1

    Break the proportion

    In drawBars change [0, 70] to [10, 70]. The 'heights are in proportion' check now fails, because a bar of 0 would still be 10 pixels. A scale for bars must start at 0, and the test knows it.

  2. 2

    Add a time zone check

    Add check("Auckland is 13 hours ahead in January", …) using the same Intl.DateTimeFormat with timeZone: "Pacific/Auckland". A January date in New Zealand is on UTC+13, so 2025-01-01T00:30:00Z is 01/01/2025, 13:30.

Code & diagrams

tests/scales.test.js (not live: needs Vitest)js

This is the real test file of the Lumen project after this phase. The first example below runs a small version of the same checks in the page.

// Tests for the pieces that can be tested without a browser: scales, data shaping, the live-feed rules, retries and CSV.
// Run them with: npm test      (vitest run)
import { describe, expect, it } from "vitest";
import * as d3 from "d3";
import { describeChange, percentChange } from "../src/parts/kpi.js";
import { ageText, withRetry } from "../src/parts/states.js";
import { applyEvent, createFeed, emptiest } from "../src/data/live.js";
import { csvCell, toCsv } from "../src/utils/export.js";

describe("scales", () => {
  const x = d3.scaleLinear().domain([0, 4380]).range([0, 560]);

  it("maps the ends and the middle of the domain", () => {
    expect(x(0)).toBe(0);
    expect(x(2190)).toBe(280);
    expect(x(4380)).toBe(560);
  });

  it("nice() rounds the domain outwards to round numbers", () => {
    expect(x.copy().nice().domain()).toEqual([0, 4500]);
  });

  it("returns the middle of the range when every value is equal", () => {
    expect(d3.scaleLinear().domain([5, 5]).range([0, 100])(5)).toBe(50);
  });

  it("gives undefined, not a number, for missing input", () => {
    expect(x(undefined)).toBeUndefined();
    expect(x(null)).toBeUndefined();
    expect(x(NaN)).toBeUndefined();
  });
});

describe("data shaping", () => {
  it("percentChange refuses to divide by zero", () => {
    expect(percentChange(120, 100)).toBe(20);
    expect(percentChange(5, 0)).toBeNull();
    expect(percentChange(5, null)).toBeNull();
    expect(percentChange(NaN, 4)).toBeNull();
  });

  it("describeChange says it in words", () => {
    expect(describeChange(3451, 3013, "vs Saturday")).toBe("up 14.5% vs Saturday");
    expect(describeChange(90, 100)).toBe("down 10.0%");
    expect(describeChange(100, 100, "vs last week")).toBe("no change vs last week");
    expect(describeChange(100, 0)).toBe("no earlier value to compare with");
  });

  it("ageText never prints a negative age", () => {
    expect(ageText(2)).toBe("just now");
    expect(ageText(185)).toBe("3 minutes ago");
    expect(ageText(-5)).toBe("at an unknown time");
  });

  it("csvCell quotes, escapes and defuses", () => {
    expect(csvCell('Quay, "North"')).toBe('"Quay, ""North"""');
    expect(csvCell("=SUM(A1:A9)")).toBe("'=SUM(A1:A9)");
    expect(csvCell(null)).toBe("");
    expect(csvCell(-3)).toBe("-3");
    expect(csvCell(new Date(Date.UTC(2025, 5, 1, 23, 30)))).toBe("2025-06-01");
  });

  it("toCsv writes a header, Windows line breaks and a byte order mark", () => {
    expect(toCsv([{ a: 1, b: "x" }], [["a", "A"], ["b", "B"]])).toBe("\ufeffA,B\r\n1,x\r\n");
  });
});

describe("the live feed", () => {
  const stations = () => new Map([["S01", { id: "S01", bikes: 10, docks: 20, seq: 0 }], ["S02", { id: "S02", bikes: 2, docks: 10, seq: 0 }]]);

  it("keeps the newest message and ignores late ones", () => {
    const state = stations();
    expect(applyEvent(state, { id: "S01", bikes: 12, seq: 5 })).toBe("applied");
    expect(applyEvent(state, { id: "S01", bikes: 3, seq: 4 })).toBe("late");
    expect(applyEvent(state, { id: "S01", bikes: 3, seq: 5 })).toBe("late");
    expect(state.get("S01").bikes).toBe(12);
  });

  it("clamps counts and rejects bad messages", () => {
    const state = stations();
    applyEvent(state, { id: "S02", bikes: 99, seq: 1 });
    expect(state.get("S02").bikes).toBe(10);
    expect(applyEvent(state, { id: "S99", bikes: 1, seq: 2 })).toBe("unknown");
    expect(applyEvent(state, { id: "S01", bikes: "3", seq: 2 })).toBe("bad");
    expect(applyEvent(state, null)).toBe("bad");
  });

  it("emptiest sorts by share and skips stations without docks", () => {
    const state = stations();
    state.set("S03", { id: "S03", bikes: 0, docks: 0, seq: 0 });
    expect(emptiest(state, 2).map((s) => s.id)).toEqual(["S02", "S01"]);
  });

  it("the same seed gives the same messages", () => {
    const rows = [{ id: "S01", bikes: 10, docks: 20 }, { id: "S02", bikes: 2, docks: 10 }];
    const run = () => {
      const seen = [];
      const feed = createFeed(rows, { seed: 7, every: 1e9 });
      feed.onmessage = (m) => seen.push(m.data);
      for (let i = 0; i < 8; i += 1) feed.send();
      feed.close();
      return seen;
    };
    expect(run()).toEqual(run());
  });
});

describe("retries", () => {
  it("waits 100 then 200 milliseconds, then succeeds", async () => {
    const waits = [];
    const result = await withRetry(async (attempt) => { if (attempt < 3) throw new Error("down"); return "ok"; }, { wait: 100, sleep: async () => {}, onRetry: (n, delay) => waits.push(delay) });
    expect(result).toBe("ok");
    expect(waits).toEqual([100, 200]);
  });

  it("does not retry a 404", async () => {
    let calls = 0;
    const missing = Object.assign(new Error("not found"), { status: 404 });
    await expect(withRetry(async () => { calls += 1; throw missing; }, { sleep: async () => {} })).rejects.toThrow("not found");
    expect(calls).toBe(1);
  });
});
Plain-function tests with a tiny runner New tab

Vitest's expect does the same job with a nicer message and a diff. The last check is wrong on purpose: its message names the expected and the real value.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

PASS scale maps the ends and the middle
PASS nice() rounds the domain outwards
PASS equal values give the middle of the range
PASS missing input gives undefined, not NaN
PASS percentChange refuses to divide by zero
PASS describeChange says it in words
PASS ageText never prints a negative age
PASS csvCell quotes and defuses
PASS a late message is ignored
PASS counts are clamped to the docks
PASS emptiest sorts by share
PASS the same seed gives the same messages
FAIL (on purpose) a wrong expectation shows both numbers - expected 300 but got 280
12 passed, 1 failed (the one on purpose)
Marks in the DOM, and time zones New tab

Time-zone names and rules come from the browser's ICU data, so a check against Atlantic/Azores gives the same answer on every machine, unlike a check against the machine's own zone.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

PASS one bar per row
PASS the tallest bar is Tuesday
PASS heights are in proportion to rides
PASS no NaN, undefined or Infinity in any attribute
PASS an empty list gives the message and no bars
PASS one row still draws one bar
PASS all values equal give equal bars
PASS winter: 00:30 UTC on 1 Jan is still 31 Dec in the Azores
PASS summer: the Azores are on UTC+0
PASS d3.autoType reads a plain date as UTC midnight
10 passed, 0 failed
A screenshot test (not live)js

The first run saves rides-line.png; later runs compare against it. Run it on one fixed browser and one operating system, since fonts differ between machines.

// tests/charts.visual.spec.js, with Playwright (npm install -D @playwright/test)
import { test, expect } from "@playwright/test";

test("the rides line looks the same", async ({ page }) => {
  await page.goto("http://localhost:5173/");
  await page.locator("#rides-line svg").waitFor();
  await expect(page.locator("#rides-line")).toHaveScreenshot("rides-line.png", { maxDiffPixelRatio: 0.01 });
});

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget that a test is asynchronous

In the first example add check("async", async () => { eq(1, 2); }); before the summary line.

terminal
$ Console
── what you'll see ──
PASS async
13 passed, 1 failed (the one on purpose)
Uncaught (in promise) Error: expected 2 but got 1

Myth vs fact

Myth

You cannot test charts because they are pictures.

Fact

Most of a chart is numbers: scales, shaped data, rules, texts. Test those, then the DOM marks. Only the last layer is a picture.

Myth

A screenshot test is the best test.

Fact

It is slow, breaks on fonts and tells you only that something changed. Use it for a few key charts on top of cheaper tests.

Myth

Dates are fine if the test passes on my laptop.

Fact

A date test that uses the machine's zone passes in one place and fails in another. Name the zone.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Test the contract of a data function with the awkward rows from daily-dirty.csv (a blank, n/a, 2,016, a duplicate day), not only clean rows.

  • ▸

    Use toBeCloseTo for pixel and float values: expect(width).toBeCloseTo(57.7, 1). Exact equality on a floating-point number is a flaky test waiting to happen.

  • ▸

    With jsdom (environment: "jsdom" in vitest.config.js) there is no layout, so getBoundingClientRect returns zeros. Test attributes you set (width, height, d), not measured sizes, or use a real browser (Playwright, Vitest's browser mode) for layout.

  • ▸

    Run the suite in two time zones in CI (TZ=Pacific/Auckland and TZ=America/Los_Angeles). It costs seconds and catches the whole class of date mistakes.

Remember this

  1. 1

    The problem this solves: a dashboard that runs all day on a wall cannot rely on someone noticing that a number is off by one day or a tile says Infinity%. Tests find those mistakes when the code changes, not when the board sees them.

  2. 2

    Test the plain functions first. Scales, data shaping (percentChange, emptiest, filter reading), formatting and the live-feed rules are functions from input to output. They need no browser, run in milliseconds and cover the places where charts go wrong silently. The project's tests/scales.test.js has 15 of them; run them with npm test.

  3. 3

    Vitest is a test runner made for Vite projects: describe groups, it names one test, expect(actual).toBe(expected) compares. It needs no setup beyond npm install -D vitest and a script "test": "vitest run". The file is shown below with its real output, and the second code block is the same checking written as a tiny check() helper so you can run it here.

  4. 4

    What to put in a test. The normal case, and every edge case from earlier phases: an empty array, one value, all values equal, zero as the earlier value, null, a late message, a date at midnight. A test that only checks the happy path would have passed for the broken code too.

  5. 5

    DOM tests check the marks. Draw the chart into an element, then ask the DOM: how many bars, is the tallest bar the biggest value, are heights in proportion, is any attribute NaN, what does the empty state say. These tests need a DOM (a real browser, or jsdom in Vitest). They check what the chart shows, not how it was written, so they survive a refactor.

  6. 6

    Time zones are a test case. A date at 2025-01-01T00:30:00Z is 1 January in London and 31 December in the Azores in winter. If code buckets days with local time, tests pass on one laptop and fail on another. Pin the time zone in the test (TZ=Pacific/Auckland npm test, or an Intl format with an explicit timeZone) and run the suite in two zones. The second example prints real conversions.

  7. 7

    Make tests deterministic. Seed random data (d3.randomLcg(42)), never log times, pass sleep and clocks in as arguments (as withRetry takes sleep), and never wait for a real second. A flaky test teaches the team to ignore failures.

  8. 8

    Visual regression, in one small layer. A tool takes a screenshot of a chart and compares it with a saved one; a difference fails the test. It catches what DOM tests cannot (a wrong colour, an overlapping label), but it breaks on fonts and antialiasing between machines, so keep it to a few key charts, on one fixed browser. A later Hectal Testing course covers it in depth.

Explain it without notes

01

Why test scales and data shaping before the DOM?

02

What would a good DOM test of a bar chart check?

03

How do you make a date test independent of the machine?

04

When is a visual regression test worth its cost?

Practice

01

Write a Vitest test for fileName(parts, ext): it must lower-case, replace odd characters with dashes and drop empty parts. Which inputs do you use?

02

Write a DOM test that drawBars with a negative number (rides: -5) does not put a negative height in the chart. What should the chart do?

Trade-offs

  • ↔

    Many small tests give fast, exact feedback but do not prove the page looks right. A few screenshots prove the look but are slow and brittle. Use both in proportion.

  • ↔

    jsdom is fast but has no layout; a real browser is accurate but slower. Use jsdom for logic and a real browser for layout.

Done when you can

  • I can test a scale and a data function with Vitest.

  • I can write the edge cases (empty, one value, equal values, zero, null) into tests.

  • I can check the marks of a chart in the DOM, including no NaN.

  • I can pin a time zone in a test and run the suite in two zones.

  • I can say what a screenshot test adds and what it costs.