write-unit-tests
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWriting tests
编写测试
Unit and integration tests use Vitest. Tests run from workspace directories, not the repo root.
单元测试和集成测试使用Vitest。测试从工作区目录运行,而非仓库根目录。
Test file locations
测试文件位置
Unit tests - alongside source files:
packages/editor/src/lib/primitives/Vec.ts
packages/editor/src/lib/primitives/Vec.test.ts # Same directoryIntegration tests - in directory:
src/test/packages/tldraw/src/test/SelectTool.test.ts
packages/tldraw/src/test/commands/createShape.test.tsShape/tool tests - alongside the implementation:
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts单元测试 - 与源文件放在同一目录:
packages/editor/src/lib/primitives/Vec.ts
packages/editor/src/lib/primitives/Vec.test.ts # 同一目录集成测试 - 位于目录:
src/test/packages/tldraw/src/test/SelectTool.test.ts
packages/tldraw/src/test/commands/createShape.test.ts形状/工具测试 - 与实现代码放在同一目录:
packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.tsWhich workspace to test in
选择哪个工作区进行测试
- packages/editor: Core primitives, geometry, managers, base editor functionality
- packages/tldraw: Anything needing default shapes/tools (most integration tests)
bash
cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "SelectTool"- packages/editor:核心基元、几何、管理器、基础编辑器功能
- packages/tldraw:任何需要默认形状/工具的场景(大多数集成测试)
bash
cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "SelectTool"TestEditor vs Editor
TestEditor vs Editor
Use for integration tests (includes default shapes/tools):
TestEditortypescript
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 when testing editor setup or custom configurations:
Editortypescript
import { Editor, createTLStore } from '@tldraw/editor'
beforeEach(() => {
editor = new Editor({
shapeUtils: [CustomShape],
bindingUtils: [],
tools: [CustomTool],
store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
getContainer: () => document.body,
})
})集成测试使用(包含默认形状/工具):
TestEditortypescript
import { createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'
let editor: TestEditor
beforeEach(() => {
editor = new TestEditor()
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
})
afterEach(() => {
editor?.dispose()
})测试编辑器设置或自定义配置时使用原生:
Editortypescript
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
常用TestEditor方法
typescript
// 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()typescript
// 指针模拟
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?)
// 键盘模拟
editor.keyDown(key, options?)
editor.keyUp(key, options?)
// 状态断言
editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.crop.idle')
// 形状断言
editor.expectShapeToMatch({ id, x, y, props: { ... } })
// 形状操作
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()
// 工具操作
editor.setCurrentTool('arrow')
editor.getCurrentToolId()
// 撤销/重做
editor.undo()
editor.redo()Pointer event options
指针事件选项
typescript
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',
})typescript
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
带形状ID的标准初始化
typescript
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()
})typescript
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
可复用属性
typescript
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 } },
])typescript
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
辅助函数
typescript
function arrow(id = ids.arrow1) {
return editor.getShape(id) as TLArrowShape
}
function bindings(id = ids.arrow1) {
return getArrowBindings(editor, arrow(id))
}typescript
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
使用vi.spyOn进行模拟
typescript
// 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()typescript
// 模拟返回值
vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)
// 模拟实现
const isHiddenSpy = vi.spyOn(editor, 'isShapeHidden')
isHiddenSpy.mockImplementation((shape) => shape.id === ids.hiddenShape)
// 验证调用
const spy = vi.spyOn(editor, 'setSelectedShapes')
editor.selectAll()
expect(spy).toHaveBeenCalled()
expect(spy).not.toHaveBeenCalled()
// 务必恢复
isHiddenSpy.mockRestore()Fake timers
假计时器
typescript
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
})typescript
vi.useFakeTimers()
// 模拟动画帧
window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60)
window.cancelAnimationFrame = (id) => clearTimeout(id)
it('handles animation', () => {
editor.alignShapes(editor.getSelectedShapeIds(), 'right')
vi.advanceTimersByTime(1000)
// 动画完成后进行断言
})Assertions
断言
Shape matching
形状匹配
typescript
// 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 } },
})typescript
// 部分匹配(最常用)
expect(editor.getShape(id)).toMatchObject({
type: 'geo',
x: 100,
props: { w: 100 },
})
editor.expectShapeToMatch({
id: ids.box1,
x: 350,
y: 350,
})
// 浮点数匹配(自定义匹配器)
expect(result).toCloselyMatchObject({
props: { normalizedAnchor: { x: 0.5, y: 0.75 } },
})Array assertions
数组断言
typescript
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)typescript
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
状态断言
typescript
editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.brushing')
editor.expectToBeIn('select.crop.idle')typescript
editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.brushing')
editor.expectToBeIn('select.crop.idle')Testing undo/redo
测试撤销/重做
typescript
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)
})typescript
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
测试TypeScript类型
typescript
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()
})typescript
it('Uses typescript generics', () => {
expect(() => {
// @ts-expect-error - 错误的属性类型
editor.createShape({ id, type: 'geo', props: { w: 'OH NO' } })
// @ts-expect-error - 未知属性
editor.createShape({ id, type: 'geo', props: { foo: 'bar' } })
// 合法
editor.createShape<TLGeoShape>({ id, type: 'geo', props: { w: 100 } })
}).toThrow()
})Testing custom shapes
测试自定义形状
typescript
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() {}
}typescript
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
测试副作用
typescript
beforeEach(() => {
editor = new TestEditor()
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
// Handle state change
}
})
})typescript
beforeEach(() => {
editor = new TestEditor()
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
// 处理状态变化
}
})
})Testing events
测试事件
typescript
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' }))
})typescript
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,
// ... 其他修饰符
})
editor.emit('tick', 16) // 刷新批量事件
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ name: 'wheel' }))
})Method chaining
方法链式调用
typescript
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()typescript
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
运行测试
bash
cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "arrow"
cd packages/editor && yarn test run --grep "Vec"bash
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
undefinedcd packages/tldraw && yarn test
undefinedKey patterns summary
核心模式总结
- Use for shape IDs
createShapeId() - Use for time-dependent behavior
vi.useFakeTimers() - Clear shapes in , dispose in
beforeEachafterEach - Test in for shapes/tools
packages/tldraw - Use for state machine assertions
expectToBeIn() - Use for partial matching
toMatchObject() - Use for floating point values
toCloselyMatchObject() - Mock with and always
vi.spyOn()mockRestore()
- 使用生成形状ID
createShapeId() - 对时间相关行为使用
vi.useFakeTimers() - 在中清除形状,在
beforeEach中销毁实例afterEach - 测试形状/工具时在中进行
packages/tldraw - 使用进行状态机断言
expectToBeIn() - 使用进行部分匹配
toMatchObject() - 使用处理浮点数
toCloselyMatchObject() - 使用进行模拟并务必调用
vi.spyOn()mockRestore()