Testing charts and migrating to v4

Test with a mocked canvas, assert on data rather than pixels, cover the interesting behaviour with integration tests, and work through the v3 to v4 changes.

Testing without a real canvas

// test/setup.js — jsdom has no canvas implementation. Provide the two calls
// Chart.js needs and keep the rest inert.
import { vi } from 'vitest';

const noop = () => {};
const stubContext = {
  canvas: null,
  save: noop, restore: noop, beginPath: noop, closePath: noop,
  moveTo: noop, lineTo: noop, arc: noop, rect: noop, roundRect: noop,
  fill: noop, stroke: noop, clip: noop, fillRect: noop, clearRect: noop,
  setLineDash: noop, measureText: () => ({ width: 0 }),
  fillText: noop, strokeText: noop,
  createLinearGradient: () => ({ addColorStop: noop })
};

HTMLCanvasElement.prototype.getContext = vi.fn(() => stubContext);
globalThis.ResizeObserver = globalThis.ResizeObserver ?? class {
  observe() {} unobserve() {} disconnect() {}
};
import { describe, it, expect, beforeEach } from 'vitest';
import { Chart } from '../src/charts/register';
import { buildRevenueChart } from '../src/charts/revenue';

describe('buildRevenueChart', () => {
  let canvas;

  beforeEach(() => {
    document.body.innerHTML = '<div style="height:300px"><canvas id="c"></canvas></div>';
    canvas = document.getElementById('c');
  });

  it('registers one dataset with the mapped values', () => {
    const chart = buildRevenueChart(canvas, [
      { quarter: 'Q1', total: 820 },
      { quarter: 'Q2', total: 932 }
    ]);

    // Assert on the chart's data model, never on pixels.
    expect(chart.data.labels).toEqual(['Q1', 'Q2']);
    expect(chart.data.datasets).toHaveLength(1);
    expect(chart.data.datasets[0].data).toEqual([820, 932]);

    chart.destroy();
  });

  it('applies the threshold colours through the scriptable option', () => {
    const chart = buildRevenueChart(canvas, [
      { quarter: 'Q1', total: 99.95 },
      { quarter: 'Q2', total: 98.4 }
    ]);

    const backgroundColor = chart.data.datasets[0].backgroundColor;
    expect(backgroundColor({ parsed: { y: 99.95 } })).toBe('rgba(16, 185, 129, 0.85)');
    expect(backgroundColor({ parsed: { y: 98.4 } })).toBe('rgba(239, 68, 68, 0.85)');

    chart.destroy();
  });

  it('formats the tooltip label', () => {
    const chart = buildRevenueChart(canvas, [{ quarter: 'Q1', total: 1290 }]);
    const label = chart.options.plugins.tooltip.callbacks.label;
    expect(label({ dataset: { label: 'Revenue' }, parsed: { y: 1290 } })).toBe(' Revenue: 1,290');
    chart.destroy();
  });
});
What to assertWhat to avoidReason
chart.data contentsPixel colours from the canvasThe data model is stable; the render is not
Scale min / maxElement positionsPositions depend on the font and the size
Option callbacks called directlyA full rendered imageCallbacks are pure functions of their arguments
chart.isDatasetVisibleDOM inspection of the canvasThe canvas has no meaningful contents
A plugin hook invoked with a fake chartA screenshot comparison as the only testVisual tests are slow and need a real browser
Legend item generationAnything from the tooltip elementThe external tooltip is your own DOM
💡
Asserting on data rather than pixels is not a compromise — it is the correct level. The questions worth testing are whether the right numbers reached the chart and whether the option functions produce the right output. Whether a bar is 3 pixels wide is the browser's job.

Integration and visual testing

// Playwright: a real browser, where layout and interaction exist.
import { test, expect } from '@playwright/test';

test('the dashboard chart renders with a resized container', async ({ page }) => {
  await page.goto('/dashboard');

  const canvas = page.locator('#revenue');
  await expect(canvas).toBeVisible();

  // Read the chart instance out of the page through a test-only handle.
  const pointCount = await page.evaluate(() => {
    const chart = window.Chart.getChart('revenue');     // by canvas id
    return chart.data.datasets[0].data.length;
  });
  expect(pointCount).toBe(12);

  // Resize and confirm the chart follows the container.
  await page.setViewportSize({ width: 480, height: 900 });
  const box = await canvas.boundingBox();
  expect(box.width).toBeLessThan(480);

  // The canvas must have a non-zero backing size, which only a real browser
  // can confirm: jsdom reports 0 for everything.
  const backing = await canvas.evaluate((el) => ({ w: el.width, h: el.height }));
  expect(backing.w).toBeGreaterThan(100);
});

test('the tooltip appears on hover', async ({ page }) => {
  await page.goto('/dashboard');
  const box = await page.locator('#revenue').boundingBox();
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await expect(page.locator('#chart-tooltip')).toHaveCSS('opacity', '1');
});
  • Chart.getChart(canvas) is the supported way to reach an instance from a test handle; it accepts a canvas element, an id or a context.
  • Only a real browser proves the chart is visible. jsdom reports every element as zero-sized, so a visibility assertion there is meaningless.
  • Screenshot comparison catches layout regressions but is brittle across font rendering and platforms. Run it on one fixed container, not on a developer laptop.
  • The most valuable integration test is usually the resize case, because that is where the option and the CSS interact in ways unit tests cannot see.

Migrating from v3 to v4

v3v4Action
import Chart from 'chart.js/auto'Still supportedNo change
CommonJS require('chart.js')ESM onlySwitch to a bundler or a dynamic import()
@types/chart.jsBuilt-in typesRemove the @types package
Tooltip custom callbackplugins.tooltip.externalRename and change the signature
scales[id].grid.borderColorborder.color on the scaleMove the value
grid.drawBorderborder.displayRewrite the option
plugins.legend.labels.boxWidth with point styleusePointStyle: 'line'Check the rendering after the change
scale.ticks.padding as a number on a radial axisObject formFollow the type error
Colour strings with a leading # onlyNamed, hex, rgb, hsl acceptedNo change
Plugin beforeDraw expectationsHook order unchangedNo change
chart.config.data mutationchart.dataUpdate the property you reach for
// v3
const optionsV3 = {
  plugins: {
    tooltip: {
      mode: 'index',
      intersect: false,
      custom: (tooltipModel) => {
        // build a DOM tooltip from tooltipModel
      }
    }
  },
  scales: {
    x: { grid: { borderColor: '#e2e8f0', drawBorder: true, drawTicks: false } }
  }
};

// v4
const optionsV4 = {
  interaction: { mode: 'index', intersect: false },   // interaction moved to the top level
  plugins: {
    tooltip: {
      external: ({ chart, tooltip }) => {
        // same idea, richer arguments: chart and tooltip
      }
    }
  },
  scales: {
    x: {
      border: { display: true, color: '#e2e8f0' },
      grid: { drawTicks: false }
    }
  }
};
# 1. Upgrade and let the type checker find the API changes.
npm install chart.js@^4
npm install --save-dev typescript
npx tsc --noEmit

# 2. Look for the calls that changed shape rather than the ones that vanished.
grep -rn "custom:" src/ | grep -i tooltip
grep -rn "drawBorder\|borderColor" src/ | grep -v node_modules

# 3. Adapters must also be current: a v1 adapter with v4 core fails at runtime.
npm install chartjs-adapter-date-fns@^3 date-fns@^3

The migration is small for code that used the documented options and painful for code that reached into internal structures — a custom tooltip built from tooltipModel, or a plugin that read chart.controller. Those need rewriting against the public API, which is the real work of the upgrade.

FAQ

How do I test a chart in jsdom?
Stub getContext and ResizeObserver in a setup file, then assert on chart.data, the scale bounds and the option callbacks. Do not assert on pixels or geometry — jsdom has no layout, so those values are all zero.
What is the riskiest part of the v3 to v4 upgrade?
Custom tooltips and plugins that used internal structures. The option renames are mechanical and the type checker catches them; a tooltip built from the old custom callback has to be rewritten against external and re-tested.

Installing Chart.js and tree-shaking the bundle Dynamic data, streaming and performance

Last refreshed 2026-09-18.