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-testsd3-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
How to use
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-pattern | Why it fails | Fix |
|---|---|---|
| outerHTML diff with all generated IDs | False positives every run | Normalize (Step 1) |
| Skip transition disable | Snapshot flake | Step 3 |
| No update-join test | enter/exit bugs ship | references/advanced-tests.md |
| Test only happy data shape | Empty / single-element / overflow data shapes break | Boundary value testing |
| Mix Chart.js + D3 in same chart | Canvas + SVG mix: snapshots inconsistent | One library per chart |
Limitations
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.
Related skills
chartjs-snapshot-tests
Snapshot-test Chart.js charts - render via headless Chromium / jsdom + canvas mock, capture canvas pixels via `canvas.toDataURL()` + image-diff, disable animations (`options.animation = false`) for stable snapshots, test tooltip + legend interactions. Use when a project renders Chart.js charts and needs regression coverage of their rendered output.
vega-spec-validator
Validate Vega + Vega-Lite specifications against the JSON Schema (vega.github.io/schema), test cross-engine compatibility (Vega-Lite compiles to Vega per the canonical compiler), and verify data-binding correctness. Pair with d3-snapshot-tests when Vega specs render to SVG; pair with chartjs-snapshot-tests when rendered to Canvas. Use when application code generates Vega/Vega-Lite JSON specs at runtime (BI builders, spec templating) and those specs must be proven valid and correctly encoded before render, or before a Vega major-version upgrade.