Skip to content

Testing Charts ​

If you want to test a canvas chart without screenshot diffing, this is how. You can assert on a chart with no browser, no canvas, no DOM — with createPlotModel alone.

Five lines ​

ts
import { createPlotModel, lineSeries } from "@finchart/core";

const model = createPlotModel({
  size: { width: 800, height: 600 },
  series: { series: lineSeries(), data },
  config: { showGrid: false },
});

const line = model.commands().find((c) => c.type === "drawLine");
expect(line?.points).toHaveLength(data.length);

model.commands() is the list of draw commands the last frame emitted — drawLine·drawText·drawShape are plain-data values, safe to serialize and compare. The list is identical to the real thing even though no canvas was ever painted — the path the core takes to build commands doesn't fork between headless and the browser.

Command shapes, series by series ​

To assert "a candle was drawn" you have to know what a candle comes out as — there is no "drawCandle" type. Every series is a combination of the primitives below (source: packages/core/src/series/__tests__/series.test.ts asserts exactly these shapes):

SeriesCommand combination
lineSeriesdrawLine (split at every gap) + drawShape circle for point markers
candleSeriesper bar, drawLine (wick) + drawShape rect (body)
barSeriesper bar, three drawLines (stem, open tick, close tick)
areaSeriesdrawShape (fill polygon) + drawLine (top line)
baselineSeriesdrawShape fills split above/below the baseline + drawLine
histogramSeriesdrawShape rect per bar

Count candle bodies with type === "drawShape" && shape.shape === "rect" (the payload field of a shape command is shape) — if a volume histogram sits in the same pane, those are rects too, so split by pane or tell them apart by color.

There really is no DOM ​

ts
const model = createPlotModel({ size: { width: 800, height: 600 } });

expect(model.plot.overlay).toBeNull();

You pass no container, no HTMLElement. model.plot is a real Plot carrying the whole state, data, decoration, and plugin API — not something cut down for headless use. So call model.plot.pan(10) and the next frame's commands actually move.

Tick labels are commands too ​

Think about having to render a PNG on the server: labels going missing by default would be a problem. So labels come out as canvas commands (drawText) too — they don't lean on a font renderer or on DOM labels.

ts
const texts = model
  .commands()
  .filter((c) => c.type === "drawText")
  .map((c) => c.params.text);

expect(texts.length).toBeGreaterThan(0);

Inject collaborators to get determinism ​

Leave the measurer (createTextMeasurer) out and the axis width is a fixed fallback — for a test that wants a picture pixel-identical to the browser's, inject the measurer yourself.

ts
const model = createPlotModel({
  size,
  series: { series: lineSeries(), data },
  config: { axis: { y: { format: () => "####" } } },
  deps: {
    createTextMeasurer: () => ({
      measure: (text) => ({ width: text.length * 10, height: 10 }),
    }),
  },
});

With that in, the axis width follows label length in headless too — the same wiring rule (which collaborator goes in) applies verbatim to browserDeps() on the browser side.

Why this works ​

Every DOM dependency in Plot sits behind a collaborator — with no container, overlay, or canvas context, the implementations that genuinely require the DOM (canvas renderer, DOM labels, dividers) throw when they try to build themselves where there is no DOM. They don't quietly do nothing — the failure surfaces immediately as a wiring error. createPlotModel only makes that boundary an official entry point — not a separate "class for tests" but the same Plot, wired to memory layers and a recording renderer.