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 runruns every*.test.jsfile 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.
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.
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.
Try it yourself
- 1
Break the proportion
In
drawBarschange[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
Add a time zone check
Add
check("Auckland is 13 hours ahead in January", …)using the sameIntl.DateTimeFormatwithtimeZone: "Pacific/Auckland". A January date in New Zealand is on UTC+13, so2025-01-01T00:30:00Zis01/01/2025, 13:30.
Code & diagrams
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);
});
});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
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)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
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 failedThe 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.
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
toBeCloseTofor 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"invitest.config.js) there is no layout, sogetBoundingClientRectreturns 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/AucklandandTZ=America/Los_Angeles). It costs seconds and catches the whole class of date mistakes.
Remember this
- 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
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'stests/scales.test.jshas 15 of them; run them withnpm test. - 3
Vitest is a test runner made for Vite projects:
describegroups,itnames one test,expect(actual).toBe(expected)compares. It needs no setup beyondnpm install -D vitestand 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 tinycheck()helper so you can run it here. - 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
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
Time zones are a test case. A date at
2025-01-01T00:30:00Zis 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 anIntlformat with an explicittimeZone) and run the suite in two zones. The second example prints real conversions. - 7
Make tests deterministic. Seed random data (
d3.randomLcg(42)), never log times, passsleepand clocks in as arguments (aswithRetrytakessleep), and never wait for a real second. A flaky test teaches the team to ignore failures. - 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
Why test scales and data shaping before the DOM?
What would a good DOM test of a bar chart check?
How do you make a date test independent of the machine?
When is a visual regression test worth its cost?
Practice
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?
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.