Writing tests
Unit and integration tests use Vitest. Tests run from workspace directories, not the repo root.
Test file locations
Unit tests - alongside source files:
packages/editor/src/lib/primitives/Vec.ts packages/editor/src/lib/primitives/Vec.test.ts # Same directory
Integration tests - in src/test/ directory:
packages/tldraw/src/test/SelectTool.test.ts packages/tldraw/src/test/commands/createShape.test.ts
Shape/tool tests - alongside the implementation:
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts
Which workspace to test in
-
packages/editor: Core primitives, geometry, managers, base editor functionality
-
packages/tldraw: Anything needing default shapes/tools (most integration tests)
cd packages/tldraw && yarn test run cd packages/tldraw && yarn test run --grep "SelectTool"
TestEditor vs Editor
Use TestEditor for integration tests (includes default shapes/tools):
import { createShapeId } from '@tldraw/editor' import { TestEditor } from './TestEditor'
let editor: TestEditor
beforeEach(() => { editor = new TestEditor() editor.selectAll().deleteShapes(editor.getSelectedShapeIds()) })
afterEach(() => { editor?.dispose() })
Use raw Editor when testing editor setup or custom configurations:
import { Editor, createTLStore } from '@tldraw/editor'
beforeEach(() => { editor = new Editor({ shapeUtils: [CustomShape], bindingUtils: [], tools: [CustomTool], store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }), getContainer: () => document.body, }) })
Common TestEditor methods
// Pointer simulation editor.pointerDown(x, y, options?) editor.pointerMove(x, y, options?) editor.pointerUp(x, y, options?) editor.click(x, y, shapeId?) editor.doubleClick(x, y, shapeId?)
// Keyboard simulation editor.keyDown(key, options?) editor.keyUp(key, options?)
// State assertions editor.expectToBeIn('select.idle') editor.expectToBeIn('select.crop.idle')
// Shape assertions editor.expectShapeToMatch({ id, x, y, props: { ... } })
// Shape operations editor.createShapes([{ id, type, x, y, props }]) editor.updateShapes([{ id, type, props }]) editor.getShape(id) editor.select(id1, id2) editor.selectAll() editor.selectNone() editor.getSelectedShapeIds() editor.getOnlySelectedShape()
// Tool operations editor.setCurrentTool('arrow') editor.getCurrentToolId()
// Undo/redo editor.undo() editor.redo()
Pointer event options
editor.pointerDown(100, 100, { target: 'shape', // 'canvas' | 'shape' | 'handle' | 'selection' shape: editor.getShape(id), })
editor.pointerDown(150, 300, { target: 'selection', handle: 'bottom', // 'top' | 'bottom' | 'left' | 'right' | corners })
editor.doubleClick(550, 550, { target: 'selection', handle: 'bottom_right', })
Setup patterns
Standard setup with shape IDs
const ids = { box1: createShapeId('box1'), box2: createShapeId('box2'), arrow1: createShapeId('arrow1'), }
vi.useFakeTimers()
beforeEach(() => { editor = new TestEditor() editor.selectAll().deleteShapes(editor.getSelectedShapeIds()) editor.createShapes([ { id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }, { id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } }, ]) })
afterEach(() => { editor?.dispose() })
Reusable props
const imageProps = { assetId: null, playing: true, url: '', w: 1200, h: 800, }
editor.createShapes([ { id: ids.imageA, type: 'image', x: 100, y: 100, props: imageProps }, { id: ids.imageB, type: 'image', x: 500, y: 500, props: { ...imageProps, w: 600, h: 400 } }, ])
Helper functions
function arrow(id = ids.arrow1) { return editor.getShape(id) as TLArrowShape }
function bindings(id = ids.arrow1) { return getArrowBindings(editor, arrow(id)) }
Mocking with vi.spyOn
// Mock return value vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)
// Mock implementation const isHiddenSpy = vi.spyOn(editor, 'isShapeHidden') isHiddenSpy.mockImplementation((shape) => shape.id === ids.hiddenShape)
// Verify calls const spy = vi.spyOn(editor, 'setSelectedShapes') editor.selectAll() expect(spy).toHaveBeenCalled() expect(spy).not.toHaveBeenCalled()
// Always restore isHiddenSpy.mockRestore()
Fake timers
vi.useFakeTimers()
// Mock animation frame window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60) window.cancelAnimationFrame = (id) => clearTimeout(id)
it('handles animation', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'right') vi.advanceTimersByTime(1000) // Assert after animation completes })
Assertions
Shape matching
// Partial matching (most common) expect(editor.getShape(id)).toMatchObject({ type: 'geo', x: 100, props: { w: 100 }, })
editor.expectShapeToMatch({ id: ids.box1, x: 350, y: 350, })
// Floating point matching (custom matcher) expect(result).toCloselyMatchObject({ props: { normalizedAnchor: { x: 0.5, y: 0.75 } }, })
Array assertions
expect(editor.getSelectedShapeIds()).toMatchObject([ids.box1]) expect(Array.from(selectedIds).sort()).toEqual([id1, id2, id3].sort()) expect(shapes).toContain('geo') expect(shapes).not.toContain(ids.lockedShape)
State assertions
editor.expectToBeIn('select.idle') editor.expectToBeIn('select.brushing') editor.expectToBeIn('select.crop.idle')
Testing undo/redo
it('handles undo/redo', () => { editor.doubleClick(550, 550, ids.image) editor.expectToBeIn('select.crop.idle')
editor.updateShape({ id: ids.image, type: 'image', props: { crop: newCrop } })
editor.undo()
editor.expectToBeIn('select.crop.idle')
expect(editor.getShape(ids.image)!.props.crop).toMatchObject(originalCrop)
editor.redo()
expect(editor.getShape(ids.image)!.props.crop).toMatchObject(newCrop)
})
Testing TypeScript types
it('Uses typescript generics', () => { expect(() => { // @ts-expect-error - wrong props type editor.createShape({ id, type: 'geo', props: { w: 'OH NO' } })
// @ts-expect-error - unknown prop
editor.createShape({ id, type: 'geo', props: { foo: 'bar' } })
// Valid
editor.createShape<TLGeoShape>({ id, type: 'geo', props: { w: 100 } })
}).toThrow()
})
Testing custom shapes
declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { 'my-custom-shape': { w: number; h: number; text: string | undefined } } }
class CustomShape extends ShapeUtil<ICustomShape> { static override type = 'my-custom-shape' static override props: RecordProps<ICustomShape> = { w: T.number, h: T.number, text: T.string.optional(), } getDefaultProps() { return { w: 200, h: 200, text: '' } } getGeometry(shape) { return new Rectangle2d({ width: shape.props.w, height: shape.props.h }) } indicator() {} component() {} }
Testing side effects
beforeEach(() => { editor = new TestEditor() editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => { if (prev.croppingShapeId !== next.croppingShapeId) { // Handle state change } }) })
Testing events
it('emits wheel events', () => { const handler = vi.fn() editor.on('event', handler)
editor.dispatch({
type: 'wheel',
name: 'wheel',
delta: { x: 0, y: 10, z: 0 },
point: { x: 100, y: 100, z: 1 },
shiftKey: false,
// ... other modifiers
})
editor.emit('tick', 16) // Flush batched events
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ name: 'wheel' }))
})
Method chaining
editor .expectToBeIn('select.idle') .select(ids.imageA, ids.imageB) .doubleClick(550, 550, { target: 'selection', handle: 'bottom_right' }) .expectToBeIn('select.idle')
editor.setCurrentTool('arrow').pointerDown(0, 0).pointerMove(100, 100).pointerUp()
Running tests
cd packages/tldraw && yarn test run cd packages/tldraw && yarn test run --grep "arrow" cd packages/editor && yarn test run --grep "Vec"
Watch mode
cd packages/tldraw && yarn test
Key patterns summary
-
Use createShapeId() for shape IDs
-
Use vi.useFakeTimers() for time-dependent behavior
-
Clear shapes in beforeEach , dispose in afterEach
-
Test in packages/tldraw for shapes/tools
-
Use expectToBeIn() for state machine assertions
-
Use toMatchObject() for partial matching
-
Use toCloselyMatchObject() for floating point values
-
Mock with vi.spyOn() and always mockRestore()