Panel testing strategy
This skill builds on frontend-testing-strategy — read that first for the general
principles every Grafana frontend test is held to (the inverted testing diamond, asserting real
behavior instead of existence, avoiding AI slop, verifying a test reaches its target branch,
generic anti-flake rules, and the SDLC-phase gating). This skill covers what's specific to
visualization code on top of that: data-frame/panel-prop builders, the canvas draw-call snapshot
harness, panel accessibility and interaction-snapshot E2E, and canvas/uPlot-specific anti-flake
rules. The visualization codeowner paths are opted into the gating
check-frontend-test-coverage.yml check, so coverage that drops fails CI.
Step 1 — Set up data with the repo's builders
Build data frames with the @grafana/data builders — pick one and don't mix
toDataFrame and createDataFrame in the same file:
import { createDataFrame, toDataFrame, arrayToDataFrame, FieldType, LoadingState } from '@grafana/data';
Use a single canonical builder per file with a Partial<> overrides object, rather than
bespoke frames per test:
function makeFrame(overrides: Partial<Options> = {}) {
/* … */
}
To render a panel component, use the shared panel-props builder instead of hand-rolling props:
import { getPanelProps } from '../test-utils'; // public/app/plugins/panel/test-utils.ts
render(<BarChartPanel {...getPanelProps(defaultOptions, { fieldConfig })} />);
Gotcha — field config. A panel unit test must call
applyFieldOverridesitself with acreateFieldConfigRegistry; the panel framework normally does this, so without it your customfieldConfig.customnever reaches the render and every case looks identical.
Gotcha — type inference. If you're testing
guessFieldTypes(or any inference), feed untyped raw fields (as unknown as DataFrameDTO).createDataFramepre-setstype, so the function under test becomes a no-op and the test gives false confidence.
Step 2 — HTML5 canvas / rendering panels: use the draw-call snapshot harness
Panels that draw to canvas (timeseries, heatmap, xychart, timeline, piechart, sparkline) are tested by capturing the ctx draw-call stream, not by pixel-diffing. Follow the established harness:
// In the harness (public/app/plugins/panel/timeseries/TimeSeriesPanel.canvasTestUtils.tsx):
import {
applyDefaultUPlotAxisMeasureTextMock,
installCanvasPath2DShim,
removeCanvasTransforms,
} from '@grafana/test-utils/canvas';
// In each *.canvas.test.tsx, mock grafana-ui's text measurement so layout is deterministic:
jest.mock('@grafana/ui/src/utils/measureText', () =>
require('@grafana/test-utils/canvas').createGrafanaUiMeasureTextJestMock(() =>
require('./TimeSeriesPanel.canvasTestUtils').getUPlotInstance()
)
);
- Split suites by concern:
*.lines.canvas.test.tsx,*.fills.…,*.annotations.…,*.axisPlacement.…,*.axisRange.…— each a focusedit.eachof cases. - Assert with the custom matcher:
expect(events).toMatchCanvasSnapshot(context, { width, height }). - Keep it deterministic (this is where flake comes from): fixed
width/height, UTC timestamps (Date.UTC(...),timeZone: 'utc'), and wait for the renderer to be ready before asserting —await waitFor(() => expect(uPlotInstance?.status).toBe(1))(awaitForcallback must throw to retry, so it needsexpect, not a bare boolean).
Step 3 — E2E for interaction, accessibility, and interaction snapshots
The DataViz strategy is unit-first. Reserve Playwright for cross-component interaction and per-panel smoke coverage. When you do write E2E:
- Add the selector to the versioned
@grafana/e2e-selectorspackage first, wiredata-testidinto the JSX, then query it (use theadd-e2e-selectorsskill). - Query by selector, never brittle CSS — in E2E
dashboardPage.getByGrafanaSelector(...), in unitscreen.getByTestId(selectors.components...).
import { test, expect } from '@grafana/plugin-e2e';
test.describe('Panels test: BarChart render', { tag: ['@panels', '@barchart'] }, () => {
test('renders without error', async ({ gotoDashboardPage, selectors }) => {
const page = await gotoDashboardPage({ uid: DASHBOARD_UID }); // provisioned devenv dashboard
await expect(page.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'))).toBeHidden();
});
});
Accessibility — every panel gets an a11y check
Every panel must have an E2E accessibility test. Use the scanForA11yViolations
fixture and the toHaveNoA11yViolations() matcher, in a describe/test tagged @a11y.
Load the panel, wait for it to actually render (assert the panel title and the chart
element are visible — an empty panel trivially passes), then scan:
test.describe('a11y', { tag: ['@a11y'] }, () => {
test('run a11y report', async ({ gotoDashboardPage, scanForA11yViolations, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ viewPanel: 'panel-4' }),
});
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('…'))).toBeVisible();
await expect(page.locator('.uplot')).toBeVisible(); // panel has drawn
const report = await scanForA11yViolations({
options: { runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'] },
});
expect(report).toHaveNoA11yViolations();
});
});
- Only pass
ignoredRulesfor a documented, tracked pre-existing violation — add a@todowith the tracking issue rather than silently ignoring (e.g.page-has-heading-one,region,color-contrastare common app-shell noise, not panel bugs). - Exemplars:
e2e-playwright/panels-suite/{histogram,xychart,table-nested,table-kitchenSink}.spec.ts, and keyboard-a11y ine2e-playwright/various-suite/panel-presets.spec.ts.
Interaction snapshots — cover a variety of states, not just first render
A panel's accessibility and structure change as the user interacts. For each panel, drive a
variety of interaction states and snapshot the resulting accessibility tree with
toMatchAriaSnapshot, re-running the a11y scan in the states that matter. Typical states
per panel type: default render, hover / tooltip open, legend item toggled, sort /
filter applied (table), series selected, panel edit mode, and empty / no-data.
const panel = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.content);
await expect(panel).toMatchAriaSnapshot(); // baseline structure
await panel.locator('.uplot').hover({ position: { x: 120, y: 80 } });
await expect(panel).toMatchAriaSnapshot(); // tooltip-open state
expect(await scanForA11yViolations()).toHaveNoA11yViolations(); // a11y holds mid-interaction
Keep these deterministic — see the canvas/uPlot anti-flake rules below (pin data, scope locators, wait for the renderer). Aria snapshots capture semantic structure, not pixels; leave pixel-level visual regression to Meticulous.
Canvas / uPlot anti-flake rules
These are the viz-specific additions to frontend-testing-strategy's generic anti-flake list.
Avoid → Do:
- Non-deterministic data / relative time ranges. Avoid
random_walk+now-30mwhen asserting on shapes or coordinates. Do pin an absolute time range and a fixed seed /startValueso the render is identical every run. (timeseries tooltip #128617) - Coordinate-based hover/click on canvas. Avoid hardcoded x/y on
.uplot. Do make data deterministic first; derive coords from rendered geometry, not constants. (xychart tooltip remains skipped #128389 for this reason) - Broad locators. Avoid
page.locator('.uplot')— it also matches option-pane preview thumbnails. Do scope:getByGrafanaSelector(Panels.Panel.content).locator('.uplot'). - Asserting before the renderer is ready. Do
waitFor(() => expect(uPlotInstance?.status).toBe(1))before any canvas snapshot/output assertion. (Sparkline/Heatmap/XYChart #127557)
Rules checklist
- Read
frontend-testing-strategyfirst — its checklist (Principles 1-4, test naming, mocking convention, generic anti-flake, SDLC gating) applies here too. - Step 1 — one data builder per file;
getPanelProps+applyFieldOverridesfor panel renders. - Step 2 — canvas panels → draw-call harness, deterministic, wait for
status === 1. - Step 3 — E2E selectors-first; every panel gets an
@a11ytest plus interaction aria-snapshots. - Canvas/uPlot anti-flake — apply all 4 rules above, on top of the generic 7.
Exemplar files
Additional exemplars not already cited inline above (Step 1 has the panel-props builder, Step 2 the canvas harness, Step 3 the a11y specs):
- Behavior-specific util tests with typed uPlot mocks &
it.each:public/app/plugins/panel/barchart/bars.test.ts - Concrete-value assertions & clear descriptions:
packages/grafana-ui/src/components/Table/{utils,cellUtils}.test.ts,packages/grafana-ui/src/components/uPlot/config/gradientFills.test.ts - E2E panel spec + shared helpers:
e2e-playwright/panels-suite/table-footer.spec.ts,e2e-playwright/panels-suite/table-utils.ts
See also the add-e2e-selectors skill, contribute/style-guides/e2e-playwright.md, and
packages/grafana-e2e-selectors/src/selectors/README.md.
Verify
yarn test <path>(add--watchAll=false) — the new tests pass and actually fail when the asserted value is broken (mutate the expected value once to confirm it's not a no-op).- For E2E:
yarn e2e:playwright <spec>(it starts its own server). yarn typecheckif selectors or casts were added.