20 KiB
Canvas and WebGL Testing
When to use: When your application renders content on
<canvas>elements -- charts (Chart.js, D3), maps (Mapbox, Leaflet), games, image editors, WebGL visualizations, drawing tools, signature pads. Prerequisites: core/assertions-and-waiting.md, core/locators.md
Quick Reference
// Screenshot comparison — the primary strategy for canvas
await expect(page.locator("canvas#chart")).toHaveScreenshot("revenue-chart.png")
// Click at specific coordinates on canvas
await page.locator("canvas").click({ position: { x: 200, y: 150 } })
// Read canvas state via page.evaluate
const pixelColor = await page.evaluate(() => {
const canvas = document.querySelector("canvas") as HTMLCanvasElement
const ctx = canvas.getContext("2d")!
const pixel = ctx.getImageData(100, 100, 1, 1).data
return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] }
})
Patterns
Screenshot Comparison (Visual Regression)
Use when: Verifying the visual output of canvas-rendered content -- charts, graphs, maps, drawings. This is the most reliable approach because canvas pixels are not queryable via DOM. Avoid when: The canvas content is dynamic on every render (animations, timestamps). Use threshold or mask options.
TypeScript
import { test, expect } from "@playwright/test"
test("revenue chart renders correctly", async ({ page }) => {
await page.goto("/dashboard")
// Wait for the chart to finish rendering
await expect(page.locator("canvas#revenue-chart")).toBeVisible()
// Optionally wait for a loading indicator to disappear
await expect(page.getByTestId("chart-loading")).toBeHidden()
// Screenshot comparison against a baseline
await expect(page.locator("canvas#revenue-chart")).toHaveScreenshot("revenue-chart.png", {
maxDiffPixelRatio: 0.01, // Allow 1% pixel difference for anti-aliasing
})
})
test("chart updates after date range change", async ({ page }) => {
await page.goto("/dashboard")
// Change date range
await page.getByRole("combobox", { name: "Date range" }).selectOption("Last 30 days")
// Wait for chart to re-render
await expect(page.getByTestId("chart-loading")).toBeHidden()
// Compare against a different baseline
await expect(page.locator("canvas#revenue-chart")).toHaveScreenshot("revenue-chart-30d.png", {
maxDiffPixelRatio: 0.01,
})
})
test("mask dynamic areas in canvas screenshot", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.locator("canvas#chart")).toHaveScreenshot("chart-stable.png", {
// Mask the timestamp area that changes every render
mask: [page.locator(".chart-timestamp")],
maxDiffPixelRatio: 0.02,
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test("revenue chart renders correctly", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.locator("canvas#revenue-chart")).toBeVisible()
await expect(page.getByTestId("chart-loading")).toBeHidden()
await expect(page.locator("canvas#revenue-chart")).toHaveScreenshot("revenue-chart.png", {
maxDiffPixelRatio: 0.01,
})
})
test("chart updates after date range change", async ({ page }) => {
await page.goto("/dashboard")
await page.getByRole("combobox", { name: "Date range" }).selectOption("Last 30 days")
await expect(page.getByTestId("chart-loading")).toBeHidden()
await expect(page.locator("canvas#revenue-chart")).toHaveScreenshot("revenue-chart-30d.png", {
maxDiffPixelRatio: 0.01,
})
})
Interacting with Canvas via Coordinates
Use when: Testing user interactions on canvas -- clicking chart data points, dragging on a drawing tool, selecting map regions. Avoid when: The element has an accessible DOM overlay (many chart libraries render tooltips as HTML). Interact with the overlay instead.
TypeScript
import { test, expect } from "@playwright/test"
test("click on chart data point shows tooltip", async ({ page }) => {
await page.goto("/analytics")
const canvas = page.locator("canvas#line-chart")
await expect(canvas).toBeVisible()
// Click at a specific coordinate on the canvas
await canvas.click({ position: { x: 200, y: 100 } })
// Tooltip appears as an HTML overlay (most chart libraries)
await expect(page.getByTestId("chart-tooltip")).toContainText("Revenue: $12,400")
})
test("draw a line on the canvas", async ({ page }) => {
await page.goto("/drawing-tool")
const canvas = page.locator("canvas#drawing-area")
// Simulate a drag to draw a line
await canvas.hover({ position: { x: 50, y: 50 } })
await page.mouse.down()
await page.mouse.move(200, 200, { steps: 10 }) // Smooth drag
await page.mouse.up()
// Verify via screenshot
await expect(canvas).toHaveScreenshot("drawn-line.png")
})
test("pinch-to-zoom on a map canvas", async ({ page }) => {
await page.goto("/map")
const canvas = page.locator("canvas#map")
// Scroll to zoom (common in map libraries)
await canvas.hover({ position: { x: 300, y: 300 } })
await page.mouse.wheel(0, -500) // Scroll up = zoom in
// Verify zoom level changed
await expect(page.getByTestId("zoom-level")).toHaveText("Zoom: 12")
})
JavaScript
const { test, expect } = require("@playwright/test")
test("click on chart data point shows tooltip", async ({ page }) => {
await page.goto("/analytics")
const canvas = page.locator("canvas#line-chart")
await expect(canvas).toBeVisible()
await canvas.click({ position: { x: 200, y: 100 } })
await expect(page.getByTestId("chart-tooltip")).toContainText("Revenue: $12,400")
})
test("draw a line on the canvas", async ({ page }) => {
await page.goto("/drawing-tool")
const canvas = page.locator("canvas#drawing-area")
await canvas.hover({ position: { x: 50, y: 50 } })
await page.mouse.down()
await page.mouse.move(200, 200, { steps: 10 })
await page.mouse.up()
await expect(canvas).toHaveScreenshot("drawn-line.png")
})
Canvas API Testing via page.evaluate()
Use when: You need to inspect canvas pixel data, read the rendering context state, or verify programmatic canvas operations. Avoid when: A screenshot comparison is sufficient. Pixel-level assertions are brittle.
TypeScript
import { test, expect } from "@playwright/test"
test("verify specific pixel color on canvas", async ({ page }) => {
await page.goto("/color-picker")
// Click the red swatch
await page.getByRole("button", { name: "Red" }).click()
// Read the pixel color at the canvas center
const color = await page.evaluate(() => {
const canvas = document.querySelector("canvas#preview") as HTMLCanvasElement
const ctx = canvas.getContext("2d")!
const centerX = Math.floor(canvas.width / 2)
const centerY = Math.floor(canvas.height / 2)
const pixel = ctx.getImageData(centerX, centerY, 1, 1).data
return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] }
})
expect(color.r).toBeGreaterThan(200) // Red channel high
expect(color.g).toBeLessThan(50) // Green channel low
expect(color.b).toBeLessThan(50) // Blue channel low
})
test("verify canvas dimensions match expected size", async ({ page }) => {
await page.goto("/editor")
const dimensions = await page.evaluate(() => {
const canvas = document.querySelector("canvas#main") as HTMLCanvasElement
return { width: canvas.width, height: canvas.height }
})
expect(dimensions).toEqual({ width: 1920, height: 1080 })
})
test("canvas has content (is not blank)", async ({ page }) => {
await page.goto("/chart")
// Wait for rendering
await expect(page.getByTestId("chart-loading")).toBeHidden()
const isBlank = await page.evaluate(() => {
const canvas = document.querySelector("canvas") as HTMLCanvasElement
const ctx = canvas.getContext("2d")!
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// Check if all pixels are transparent (blank canvas)
return imageData.data.every((value, index) => {
return index % 4 === 3 ? value === 0 : true // Check alpha channel
})
})
expect(isBlank).toBe(false)
})
JavaScript
const { test, expect } = require("@playwright/test")
test("verify specific pixel color on canvas", async ({ page }) => {
await page.goto("/color-picker")
await page.getByRole("button", { name: "Red" }).click()
const color = await page.evaluate(() => {
const canvas = document.querySelector("canvas#preview")
const ctx = canvas.getContext("2d")
const centerX = Math.floor(canvas.width / 2)
const centerY = Math.floor(canvas.height / 2)
const pixel = ctx.getImageData(centerX, centerY, 1, 1).data
return { r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] }
})
expect(color.r).toBeGreaterThan(200)
expect(color.g).toBeLessThan(50)
expect(color.b).toBeLessThan(50)
})
test("canvas has content (is not blank)", async ({ page }) => {
await page.goto("/chart")
await expect(page.getByTestId("chart-loading")).toBeHidden()
const isBlank = await page.evaluate(() => {
const canvas = document.querySelector("canvas")
const ctx = canvas.getContext("2d")
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
return imageData.data.every((value, index) => {
return index % 4 === 3 ? value === 0 : true
})
})
expect(isBlank).toBe(false)
})
WebGL Rendering Verification
Use when: Your app uses WebGL for 3D visualizations, data plots, or games. Avoid when: The canvas uses 2D context only.
WebGL canvas cannot be read with getImageData from a 2D context. Use toDataURL() or screenshot comparison.
TypeScript
import { test, expect } from "@playwright/test"
test("WebGL scene renders a 3D model", async ({ page }) => {
await page.goto("/3d-viewer")
// Wait for WebGL to finish rendering
await page.waitForFunction(() => {
const canvas = document.querySelector("canvas#scene") as HTMLCanvasElement
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl")
return gl !== null
})
// Give the renderer time to complete the first frame
await expect(page.getByTestId("render-status")).toHaveText("Ready")
// Screenshot comparison is the most reliable approach for WebGL
await expect(page.locator("canvas#scene")).toHaveScreenshot("3d-model.png", {
maxDiffPixelRatio: 0.02, // WebGL has more rendering variance
})
})
test("WebGL canvas is not blank", async ({ page }) => {
await page.goto("/3d-viewer")
await expect(page.getByTestId("render-status")).toHaveText("Ready")
// Check that the WebGL canvas has drawn something
const hasContent = await page.evaluate(() => {
const canvas = document.querySelector("canvas#scene") as HTMLCanvasElement
// Convert canvas to data URL and check it's not a blank image
const dataUrl = canvas.toDataURL()
// A blank canvas produces a very short data URL
return dataUrl.length > 1000
})
expect(hasContent).toBe(true)
})
test("rotate 3D model and verify new angle", async ({ page }) => {
await page.goto("/3d-viewer")
await expect(page.getByTestId("render-status")).toHaveText("Ready")
// Take baseline screenshot
const canvas = page.locator("canvas#scene")
// Drag to rotate
await canvas.hover({ position: { x: 300, y: 300 } })
await page.mouse.down()
await page.mouse.move(450, 300, { steps: 20 })
await page.mouse.up()
// Screenshot should differ from default angle
await expect(canvas).toHaveScreenshot("3d-model-rotated.png", {
maxDiffPixelRatio: 0.02,
})
})
JavaScript
const { test, expect } = require("@playwright/test")
test("WebGL scene renders a 3D model", async ({ page }) => {
await page.goto("/3d-viewer")
await page.waitForFunction(() => {
const canvas = document.querySelector("canvas#scene")
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl")
return gl !== null
})
await expect(page.getByTestId("render-status")).toHaveText("Ready")
await expect(page.locator("canvas#scene")).toHaveScreenshot("3d-model.png", {
maxDiffPixelRatio: 0.02,
})
})
test("WebGL canvas is not blank", async ({ page }) => {
await page.goto("/3d-viewer")
await expect(page.getByTestId("render-status")).toHaveText("Ready")
const hasContent = await page.evaluate(() => {
const canvas = document.querySelector("canvas#scene")
const dataUrl = canvas.toDataURL()
return dataUrl.length > 1000
})
expect(hasContent).toBe(true)
})
Chart Library Testing Strategies
Use when: Testing Chart.js, D3, Recharts, Highcharts, or similar chart libraries. Avoid when: Charts have full HTML/SVG DOM output (D3 with SVG). Use standard locators for SVG elements.
TypeScript
import { test, expect } from "@playwright/test"
test("bar chart shows correct number of bars (SVG-based chart)", async ({ page }) => {
await page.goto("/analytics")
// SVG-based charts (D3, Recharts) render DOM elements — use locators
const bars = page.locator("svg.chart rect.bar")
await expect(bars).toHaveCount(12) // 12 months
// Check a specific bar's aria-label or tooltip
await bars.nth(0).hover()
await expect(page.getByTestId("chart-tooltip")).toContainText("January: $8,200")
})
test("canvas-based chart displays data (Chart.js)", async ({ page }) => {
await page.goto("/analytics")
// Canvas charts — no DOM elements to query, use screenshot
await expect(page.getByTestId("chart-loading")).toBeHidden()
await expect(page.locator("canvas#monthly-chart")).toHaveScreenshot("monthly-chart.png")
})
test("chart legend toggles data series", async ({ page }) => {
await page.goto("/analytics")
// Click legend item (usually HTML, not canvas)
await page.getByRole("button", { name: "Revenue" }).click()
// Revenue series hidden — chart should look different
await expect(page.locator("canvas#chart")).toHaveScreenshot("chart-no-revenue.png")
// Click again to re-enable
await page.getByRole("button", { name: "Revenue" }).click()
await expect(page.locator("canvas#chart")).toHaveScreenshot("chart-with-revenue.png")
})
test("export chart as image", async ({ page }) => {
await page.goto("/analytics")
// Many chart libraries offer "download as PNG"
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export as PNG" }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/chart.*\.png$/)
})
JavaScript
const { test, expect } = require("@playwright/test")
test("bar chart shows correct number of bars (SVG-based)", async ({ page }) => {
await page.goto("/analytics")
const bars = page.locator("svg.chart rect.bar")
await expect(bars).toHaveCount(12)
await bars.nth(0).hover()
await expect(page.getByTestId("chart-tooltip")).toContainText("January: $8,200")
})
test("canvas-based chart displays data", async ({ page }) => {
await page.goto("/analytics")
await expect(page.getByTestId("chart-loading")).toBeHidden()
await expect(page.locator("canvas#monthly-chart")).toHaveScreenshot("monthly-chart.png")
})
Decision Guide
| Scenario | Best Approach | Why |
|---|---|---|
| Verify chart looks correct | toHaveScreenshot() on canvas element |
Canvas pixels are not DOM; screenshot is the source of truth |
| Click a data point on chart | canvas.click({ position: { x, y } }) |
Canvas does not have clickable child elements |
| Verify canvas is not blank | page.evaluate + getImageData or toDataURL |
Quick programmatic check without baseline image |
| Test SVG-based chart (D3) | Standard locators (svg rect, svg path) |
SVG elements are in the DOM; use locator queries |
| Read specific pixel color | page.evaluate + getImageData |
Direct access to pixel data |
| Test WebGL rendering | toHaveScreenshot() with higher maxDiffPixelRatio |
WebGL has rendering variance; pixel assertions are unreliable |
| Test canvas drag/draw | mouse.down() + mouse.move() + mouse.up() |
Simulates real drawing interactions |
| Chart tooltip after hover | canvas.hover({ position }) then assert tooltip DOM |
Tooltips are usually HTML overlays |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
page.getByRole('button') inside a canvas |
Canvas content has no DOM elements | Use canvas.click({ position }) for coordinates |
| Assert pixel colors with exact RGB values | Anti-aliasing and GPU differences cause 1-2 value variance | Use ranges (toBeGreaterThan(200)) or toHaveScreenshot |
Skip maxDiffPixelRatio in canvas screenshots |
Different GPUs and OS versions render slightly differently | Set maxDiffPixelRatio: 0.01 to 0.02 |
waitForTimeout to wait for chart render |
Arbitrary; too slow or too fast | Wait for a loading indicator to disappear or use waitForFunction |
Read WebGL pixels via 2D context getImageData |
WebGL and 2D contexts are mutually exclusive on the same canvas | Use canvas.toDataURL() or screenshot comparison |
| Take full-page screenshots for canvas tests | Captures unrelated content; more brittle baselines | Scope screenshot to page.locator('canvas#specific') |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Screenshot baseline always differs | GPU rendering differences across CI and local machine | Use Docker with consistent GPU settings, or increase maxDiffPixelRatio |
| Canvas click at coordinates hits wrong element | Coordinates are relative to element, but viewport changed | Use position relative to the canvas element, not the page |
getImageData returns all transparent pixels |
Canvas has not finished rendering when evaluated | Wait for a render-complete signal or use waitForFunction |
toDataURL throws SecurityError |
Canvas is tainted by cross-origin image | Serve images from the same origin or use CORS headers |
| WebGL context is null | Browser does not support WebGL or it is disabled in CI | Use --enable-webgl flag or run tests on a GPU-capable CI runner |
| Screenshot test passes locally, fails in CI | Different font rendering, DPI, or OS | Pin the Docker image, use fonts config, or increase threshold |
Related
- core/assertions-and-waiting.md -- auto-retrying assertions and visual comparison options
- core/configuration.md -- configure screenshot thresholds and update baselines
- core/iframes-and-shadow-dom.md -- canvas elements inside iframes
- core/debugging.md -- debugging visual regression failures with trace viewer