$npx -y skills add aisa-group/skill-inject --skill write-unit-testsWriting unit and integration tests for the tldraw SDK. Use when creating new tests, adding test coverage, or fixing failing tests in packages/editor or packages/tldraw. Covers Vitest patterns, TestEditor usage, and test file organization.
| 1 | # Writing tests |
| 2 | |
| 3 | Unit and integration tests use Vitest. Tests run from workspace directories, not the repo root. |
| 4 | |
| 5 | ## Test file locations |
| 6 | |
| 7 | **Unit tests** - alongside source files: |
| 8 | |
| 9 | ``` |
| 10 | packages/editor/src/lib/primitives/Vec.ts |
| 11 | packages/editor/src/lib/primitives/Vec.test.ts # Same directory |
| 12 | ``` |
| 13 | |
| 14 | **Integration tests** - in `src/test/` directory: |
| 15 | |
| 16 | ``` |
| 17 | packages/tldraw/src/test/SelectTool.test.ts |
| 18 | packages/tldraw/src/test/commands/createShape.test.ts |
| 19 | ``` |
| 20 | |
| 21 | **Shape/tool tests** - alongside the implementation: |
| 22 | |
| 23 | ``` |
| 24 | packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts |
| 25 | packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts |
| 26 | ``` |
| 27 | |
| 28 | ## Which workspace to test in |
| 29 | |
| 30 | - **packages/editor**: Core primitives, geometry, managers, base editor functionality |
| 31 | - **packages/tldraw**: Anything needing default shapes/tools (most integration tests) |
| 32 | |
| 33 | ```bash |
| 34 | cd packages/tldraw && yarn test run |
| 35 | cd packages/tldraw && yarn test run --grep "SelectTool" |
| 36 | ``` |
| 37 | |
| 38 | ## TestEditor vs Editor |
| 39 | |
| 40 | Use `TestEditor` for integration tests (includes default shapes/tools): |
| 41 | |
| 42 | ```typescript |
| 43 | import { createShapeId } from '@tldraw/editor' |
| 44 | import { TestEditor } from './TestEditor' |
| 45 | |
| 46 | let editor: TestEditor |
| 47 | |
| 48 | beforeEach(() => { |
| 49 | editor = new TestEditor() |
| 50 | editor.selectAll().deleteShapes(editor.getSelectedShapeIds()) |
| 51 | }) |
| 52 | |
| 53 | afterEach(() => { |
| 54 | editor?.dispose() |
| 55 | }) |
| 56 | ``` |
| 57 | |
| 58 | Use raw `Editor` when testing editor setup or custom configurations: |
| 59 | |
| 60 | ```typescript |
| 61 | import { Editor, createTLStore } from '@tldraw/editor' |
| 62 | |
| 63 | beforeEach(() => { |
| 64 | editor = new Editor({ |
| 65 | shapeUtils: [CustomShape], |
| 66 | bindingUtils: [], |
| 67 | tools: [CustomTool], |
| 68 | store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }), |
| 69 | getContainer: () => document.body, |
| 70 | }) |
| 71 | }) |
| 72 | ``` |
| 73 | |
| 74 | ## Common TestEditor methods |
| 75 | |
| 76 | ```typescript |
| 77 | // Pointer simulation |
| 78 | editor.pointerDown(x, y, options?) |
| 79 | editor.pointerMove(x, y, options?) |
| 80 | editor.pointerUp(x, y, options?) |
| 81 | editor.click(x, y, shapeId?) |
| 82 | editor.doubleClick(x, y, shapeId?) |
| 83 | |
| 84 | // Keyboard simulation |
| 85 | editor.keyDown(key, options?) |
| 86 | editor.keyUp(key, options?) |
| 87 | |
| 88 | // State assertions |
| 89 | editor.expectToBeIn('select.idle') |
| 90 | editor.expectToBeIn('select.crop.idle') |
| 91 | |
| 92 | // Shape assertions |
| 93 | editor.expectShapeToMatch({ id, x, y, props: { ... } }) |
| 94 | |
| 95 | // Shape operations |
| 96 | editor.createShapes([{ id, type, x, y, props }]) |
| 97 | editor.updateShapes([{ id, type, props }]) |
| 98 | editor.getShape(id) |
| 99 | editor.select(id1, id2) |
| 100 | editor.selectAll() |
| 101 | editor.selectNone() |
| 102 | editor.getSelectedShapeIds() |
| 103 | editor.getOnlySelectedShape() |
| 104 | |
| 105 | // Tool operations |
| 106 | editor.setCurrentTool('arrow') |
| 107 | editor.getCurrentToolId() |
| 108 | |
| 109 | // Undo/redo |
| 110 | editor.undo() |
| 111 | editor.redo() |
| 112 | ``` |
| 113 | |
| 114 | ## Pointer event options |
| 115 | |
| 116 | ```typescript |
| 117 | editor.pointerDown(100, 100, { |
| 118 | target: 'shape', // 'canvas' | 'shape' | 'handle' | 'selection' |
| 119 | shape: editor.getShape(id), |
| 120 | }) |
| 121 | |
| 122 | editor.pointerDown(150, 300, { |
| 123 | target: 'selection', |
| 124 | handle: 'bottom', // 'top' | 'bottom' | 'left' | 'right' | corners |
| 125 | }) |
| 126 | |
| 127 | editor.doubleClick(550, 550, { |
| 128 | target: 'selection', |
| 129 | handle: 'bottom_right', |
| 130 | }) |
| 131 | ``` |
| 132 | |
| 133 | ## Setup patterns |
| 134 | |
| 135 | ### Standard setup with shape IDs |
| 136 | |
| 137 | ```typescript |
| 138 | const ids = { |
| 139 | box1: createShapeId('box1'), |
| 140 | box2: createShapeId('box2'), |
| 141 | arrow1: createShapeId('arrow1'), |
| 142 | } |
| 143 | |
| 144 | vi.useFakeTimers() |
| 145 | |
| 146 | beforeEach(() => { |
| 147 | editor = new TestEditor() |
| 148 | editor.selectAll().deleteShapes(editor.getSelectedShapeIds()) |
| 149 | editor.createShapes([ |
| 150 | { id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }, |
| 151 | { id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } }, |
| 152 | ]) |
| 153 | }) |
| 154 | |
| 155 | afterEach(() => { |
| 156 | editor?.dispose() |
| 157 | }) |
| 158 | ``` |
| 159 | |
| 160 | ### Reusable props |
| 161 | |
| 162 | ```typescript |
| 163 | const imageProps = { |
| 164 | assetId: null, |
| 165 | playing: true, |
| 166 | url: '', |
| 167 | w: 1200, |
| 168 | h: 800, |
| 169 | } |
| 170 | |
| 171 | editor.createShapes([ |
| 172 | { id: ids.imageA, type: 'image', x: 100, y: 100, props: imageProps }, |
| 173 | { id: ids.imageB, type: 'image', x: 500, y: 500, props: { ...imageProps, w: 600, h: 400 } }, |
| 174 | ]) |
| 175 | ``` |
| 176 | |
| 177 | ### Helper functions |
| 178 | |
| 179 | ```typescript |
| 180 | function arrow(id = ids.arrow1) { |
| 181 | return editor.getShape(id) as TLArrowShape |
| 182 | } |
| 183 | |
| 184 | function bindings(id = ids.arrow1) { |
| 185 | return getArrowBindings(editor, arrow(id)) |
| 186 | } |
| 187 | ``` |
| 188 | |
| 189 | ## Mocking with vi.spyOn |
| 190 | |
| 191 | ```typescript |
| 192 | // Mock return value |
| 193 | vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true) |
| 194 | |
| 195 | // Mock implementation |
| 196 | const isHiddenSpy = vi.spyOn(editor, 'isShapeHidden') |
| 197 | isHiddenSpy.mockImplementation((shape) => shape.id === ids.hiddenShape) |
| 198 | |
| 199 | // Verify calls |
| 200 | const spy = vi.spyOn(editor, 'setSelectedShapes') |
| 201 | editor.selectAll() |
| 202 | expect(spy).toHaveBeenCalled() |
| 203 | expect(spy).not.toHaveBeenCalled() |
| 204 | |
| 205 | // Always restore |
| 206 | isHiddenSpy.mockRestore() |
| 207 | ``` |
| 208 | |
| 209 | ## Fake timers |
| 210 | |
| 211 | ```typescript |
| 212 | vi.useFakeTimers() |
| 213 | |
| 214 | // Mock animation frame |
| 215 | window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60) |
| 216 | window.cancelAnimationFrame = (id) => clearTimeout(id) |
| 217 | |
| 218 | it('handles animation', () => { |
| 219 | editor.al |