Skip to main content

How to test react-konva components

Testing canvas is not like testing DOM. Your shapes are not elements, so Testing Library queries never find them, and the environment most React projects test in cannot draw.

Run canvas tests in a real browser

Use jsdom for your store and your logic. Use a real browser for anything that touches a Stage.

This matters more than it sounds, because jsdom does not fail loudly. There is no canvas implementation in it, so the usual fix is a mock that returns empty values from every context call. Nothing throws. Your test goes green. What you are actually asserting is:

What you testWhat jsdom gives you
stage.getIntersection(pos)null, whatever is under the point
text.width() after measuringa value from a font that was never loaded
stage.toDataURL()a stub string, not an image
pixel readbackfully transparent

Only the parts that are pure JavaScript — find(), attributes, the scene graph — behave correctly. So a hit-testing test passes without testing hit testing. That is the one failure a coding agent cannot catch for you: it writes the test, the test is green, and the loop ends.

react-konva itself is tested this way. Its config is short:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';

export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
headless: process.env.HEADLESS !== 'false',
instances: [{ browser: 'chromium' }],
},
globals: true,
},
});

If you have existing jsdom tests, keep them. Split the run into two projects — one jsdom for logic, one browser for canvas — rather than moving everything.

Getting hold of the stage

A ref is the cleanest route:

const stageRef = React.useRef(null);
render(<Stage ref={stageRef} width={200} height={200}></Stage>);

stageRef.current.find('#target');

When the component under test owns its own stage and gives you no ref, Konva keeps a registry:

import Konva from 'konva';

const stage = Konva.stages[Konva.stages.length - 1];

data-testid on <Stage> does not work. The component renders a container div and forwards only id, accessKey, className, role, style, tabIndex and title. Anything else, including data-*, is dropped. Use id if you want to query the container, and remember the container is a div — the <canvas> elements live inside it.

Assert on the scene graph, not the DOM

Shapes are Konva nodes. Query them the way Konva does:

const rect = stage.findOne('#card');

expect(rect.width()).toBe(120);
expect(stage.find('Circle')).toHaveLength(3);
expect(stage.find('.selected')).toHaveLength(1); // .name, not a CSS class

find('Rect') matches by node type, find('.name') by the name attribute, and findOne('#id') by id. None of this needs the browser to have painted anything, so these assertions are the fast, reliable core of a canvas test suite.

For anything about appearance — did this actually render, is it the right colour — you need real pixels, which is the other reason for the browser:

const ctx = layer.getContext();
const ratio = layer.getCanvas().getPixelRatio();
const { data } = ctx.getImageData(x * ratio, y * ratio, 1, 1);

expect([data[0], data[1], data[2]]).toEqual([255, 0, 0]);

Scale the coordinates by the layer's pixel ratio, or you sample the wrong pixel on a retina screen. See Blurry canvas for why.

Simulating a drag

Konva does not listen for mousemove and mouseup on the shape, or even on the stage container. It attaches them to window when a drag begins, so the drag survives the pointer leaving the canvas. A synthetic drag has to follow that:

const container = stage.container();

container.dispatchEvent(new MouseEvent('mousedown', { clientX: 20, clientY: 20, bubbles: true }));
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 90, clientY: 60, bubbles: true }));
window.dispatchEvent(new MouseEvent('mouseup', { clientX: 90, clientY: 60, bubbles: true }));

Two things to know:

  • Dispatch MouseEvent, not only PointerEvent. Konva's drag handling is driven by the mouse family, so a pointer-only sequence can be ignored and the drag becomes a silent no-op. Dispatching both is the safe option.
  • Konva.dragDistance defaults to 3 pixels. A move of one or two pixels is treated as a click and dragstart never fires. Move further than that, or set the threshold in the test.

react-konva flushes React updates after each Konva event handler. You do not need an extra microtask between events. Wait only if your own handler starts asynchronous work.

Clean up, and check nothing leaked

Every stage registers itself in Konva.stages. If a test unmounts and a stage survives, later tests inherit it and start failing in confusing ways. Assert on that rather than hoping:

afterEach(async () => {
cleanup();
// Stage.destroy() is deferred a tick so a StrictMode remount can reuse it.
await new Promise((r) => setTimeout(r, 0));

const leaked = Konva.stages.length;
[...Konva.stages].forEach((s) => s.destroy());

expect(leaked).toBe(0);
});

End-to-end tests

For a full application, drive a real browser with Playwright and skip synthetic events entirely — real input reaches window naturally, so the trap above does not apply:

const start = await page.evaluate(() => {
const stage = window.Konva.stages[0];
return stage.findOne('#card').position();
});

await page.mouse.move(120, 120); // A point inside #card.
await page.mouse.down();
await page.mouse.move(260, 200);
await page.mouse.up();

const pos = await page.evaluate(() => {
const stage = window.Konva.stages[0];
return stage.findOne('#card').position();
});
expect(pos).toEqual({ x: start.x + 140, y: start.y + 80 });

Konva preserves the pointer offset inside the node. It checks dragDistance against the total displacement from the start, so one move of more than three pixels can start a drag.

Plain Konva without React

Konva runs in Node with a backend module, which is a different setup from everything above. See Node.js — and note that Konva prints the exact install and import lines if you forget them.