Testland
Browse all skills & agents

d3-snapshot-tests

Snapshot-test D3.js charts - D3 generates SVG (not Canvas, per d3js.org getting-started); use `outerHTML` snapshot for static structure, `toHaveScreenshot` for rendered SVG; jsdom for headless render in unit tests; disable transitions for stable snapshots; per-element data-binding correctness tests. Use when a project renders charts with `d3.create('svg')` or D3 selections and the emitted SVG structure or update join needs regression coverage - including before a D3 major-version upgrade.

Install with skills.sh (any agent)

npx skills add testland/qa --skill d3-snapshot-tests
View source

d3-snapshot-tests

Per the D3 getting-started docs (opens in new window), D3 "generates SVG output (not Canvas). Code examples show creation of SVG elements with d3.create('svg') and DOM manipulation via selections." SVG is text-DOM, so snapshots can be the rendered SVG markup OR a rendered image - different test patterns for each.

When to use

  • Custom D3 viz library where DOM structure is the contract.
  • Dashboards using Observable Plot / D3-based React libs (Visx, Nivo, Recharts).
  • Pre-deploy gate before D3 major upgrade (D3 v6 → v7 changed module imports).

How to use

  1. Confirm the chart renders SVG via d3.create('svg') or D3 selections (Canvas belongs to chartjs-snapshot-tests).
  2. Pick the snapshot mode: outerHTML for the structural contract (Step 1) or toHaveScreenshot for rendered pixels (Step 2).
  3. Disable D3 transition() in test mode so snapshots are stable (Step 3).
  4. Add a data-binding test: one element per data point, per-element attributes track the data (Step 4).
  5. For fast headless runs, render the generator under jsdom in a unit test (Step 5).
  6. Cover the update join and SVG a11y metadata via references/advanced-tests.md.
  7. Normalize generated IDs before diffing; run the whole suite as a gate before any D3 major-version upgrade.

Worked example

A revenue bar chart renders svg.bar-chart from [10, 20, 30, 40]. Capture its outerHTML, pass it through normalizeSvg (Step 1) to strip generated IDs, and store bar-chart.svg.txt. Add a data-binding test (Step 4) asserting 4 rects and heights[3] > heights[0] (data 40 > 10). The 750 ms enter transition is swapped for the identity function in test mode (Step 3) so the snapshot is deterministic. Suite runs green. Later a D3 v6 → v7 upgrade drops a <g> wrapper import; the outerHTML snapshot diff flags the missing group before it ships.

Step 1 - outerHTML structural snapshot

For tests where SVG structure should match exactly:

import { test, expect } from '@playwright/test';

test('bar chart SVG has expected structure', async ({ page }) => {
  await page.goto('https://localhost:3000/d3-bar');
  await page.waitForSelector('svg.bar-chart');

  const svgHtml = await page.locator('svg.bar-chart').evaluate(el => el.outerHTML);

  // Compare to stored fixture
  expect(normalizeSvg(svgHtml)).toMatchSnapshot('bar-chart.svg.txt');
});

normalizeSvg strips dynamically-generated IDs (__id__123) + whitespace differences:

function normalizeSvg(svg: string): string {
  return svg
    .replace(/id="[^"]*-\d+"/g, 'id="ID"')
    .replace(/\s+/g, ' ')
    .trim();
}

Step 2 - Rendered-image snapshot (Playwright)

For visual-regression-style:

test('scatter plot renders correctly', async ({ page }) => {
  await page.goto('https://localhost:3000/d3-scatter');
  await page.waitForSelector('svg.scatter');

  await expect(page.locator('svg.scatter')).toHaveScreenshot('scatter.png', {
    maxDiffPixels: 50,
  });
});

Per Chart.js docs (opens in new window) equivalent works for D3 too - snapshot the locator, not the page.

Step 3 - Disable transitions

D3 transition() calls animate. Disable for tests:

// Instead of d3.select(...).transition().duration(750).attr(...)
// In test mode:
const transition = process.env.NODE_ENV === 'test'
  ? (sel) => sel  // identity
  : (sel) => sel.transition().duration(750);

transition(d3.select('.bars').selectAll('rect'))
  .attr('width', d => x(d.value));

Or use d3.transition().duration(0) if API can't be conditional.

Step 4 - Per-element data-binding test

D3's strength is data-driven DOM. Test the binding holds:

test('one rect per data point', async ({ page }) => {
  const data = [10, 20, 30, 40];
  await page.goto(`https://localhost:3000/d3-bar?data=${JSON.stringify(data)}`);
  await page.waitForSelector('svg.bar-chart rect');

  const rects = await page.locator('svg.bar-chart rect').count();
  expect(rects).toBe(data.length);

  // Per-rect height matches data
  const heights = await page.locator('svg.bar-chart rect').evaluateAll(els =>
    els.map(el => parseFloat(el.getAttribute('height')!))
  );
  expect(heights[3]).toBeGreaterThan(heights[0]);  // data[3]=40 > data[0]=10
});

Step 5 - jsdom unit test (fast)

import { JSDOM } from 'jsdom';
import * as d3 from 'd3';

test('bar generator emits N rects for N data points', () => {
  const dom = new JSDOM('<svg id="chart"></svg>');
  global.document = dom.window.document;

  const data = [1, 2, 3];
  d3.select(dom.window.document.body)
    .select('svg')
    .selectAll('rect')
    .data(data)
    .join('rect')
    .attr('height', d => d);

  const rects = dom.window.document.querySelectorAll('rect');
  expect(rects).toHaveLength(3);
});

Per the D3 getting-started docs (opens in new window), D3 imports cleanly under ESM - jsdom + native ESM works.

Advanced correctness tests

Update join (enter / update / exit) and SVG accessibility metadata are the deepest D3 test patterns - see references/advanced-tests.md.

Anti-patterns

Anti-patternWhy it failsFix
outerHTML diff with all generated IDsFalse positives every runNormalize (Step 1)
Skip transition disableSnapshot flakeStep 3
No update-join testenter/exit bugs shipreferences/advanced-tests.md
Test only happy data shapeEmpty / single-element / overflow data shapes breakBoundary value testing
Mix Chart.js + D3 in same chartCanvas + SVG mix: snapshots inconsistentOne library per chart

Limitations

  • jsdom doesn't compute SVG layout (no getBBox()). Tests that require measured positions need a real browser.
  • D3's modular structure means d3 (full bundle) ≠ individual modules (d3-selection, d3-array); pin import strategy.
  • D3 generates <title> tooltip natively; some libs override - test the actual rendering.

References

d3-snapshot-tests - advanced correctness tests

View source (opens in new window)

d3-snapshot-tests - advanced correctness tests

Deeper D3 test patterns split out of the SKILL spine: the update join (enter / update / exit) and SVG accessibility metadata.

Update join correctness

D3's update join (enter / update / exit) is the hardest D3 concept to test. Test the three states:

test('update join handles insert + remove + reorder', async ({ page }) => {
  await page.goto('https://localhost:3000/d3-update');

  // Initial: [A, B, C]
  await page.evaluate(() => (window as any).updateChart(['A', 'B', 'C']));
  expect(await page.locator('rect[data-key="A"]').count()).toBe(1);

  // After: [A, B, D] - remove C, add D
  await page.evaluate(() => (window as any).updateChart(['A', 'B', 'D']));
  expect(await page.locator('rect[data-key="C"]').count()).toBe(0);
  expect(await page.locator('rect[data-key="D"]').count()).toBe(1);

  // After: [B, D, A] - reorder; element identity preserved
  await page.evaluate(() => (window as any).updateChart(['B', 'D', 'A']));
  // 'A' should be the same DOM node (just repositioned)
  // Verify via attribute or event listener attached pre-reorder
});

Use a stable key function: data-bind by .data(arr, d => d.id).

Accessibility metadata

D3 generates SVG; SVG has accessibility primitives. Tests verify:

test('chart has title + desc for screen readers', async ({ page }) => {
  await page.goto('https://localhost:3000/d3-bar');

  await expect(page.locator('svg.bar-chart > title')).toContainText('Revenue by Quarter');
  await expect(page.locator('svg.bar-chart > desc')).toContainText('Bar chart showing');
});

test('rects have aria-labels', async ({ page }) => {
  const labels = await page.locator('svg.bar-chart rect').evaluateAll(els =>
    els.map(el => el.getAttribute('aria-label'))
  );
  expect(labels[0]).toBe('Q1 revenue: $10k');
});

Cross-ref qa-accessibility plugin for broader a11y patterns.