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 inafterEach - Test in
packages/tldrawfor shapes/tools - Use
expectToBeIn()for state machine assertions - Use
toMatchObject()for partial matching - Use
toCloselyMatchObject()for floating point values - Mock with
vi.spyOn()and alwaysmockRestore()