write-unit-tests

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Writing 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 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
单元测试 - 与源文件放在同一目录:
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.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)
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
TestEditor
for integration tests (includes default shapes/tools):
typescript
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:
typescript
import { Editor, createTLStore } from '@tldraw/editor'

beforeEach(() => {
	editor = new Editor({
		shapeUtils: [CustomShape],
		bindingUtils: [],
		tools: [CustomTool],
		store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
		getContainer: () => document.body,
	})
})
集成测试使用
TestEditor
(包含默认形状/工具):
typescript
import { createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'

let editor: TestEditor

beforeEach(() => {
	editor = new TestEditor()
	editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
})

afterEach(() => {
	editor?.dispose()
})
测试编辑器设置或自定义配置时使用原生
Editor
typescript
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
undefined
cd packages/tldraw && yarn test
undefined

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()
  • 使用
    createShapeId()
    生成形状ID
  • 对时间相关行为使用
    vi.useFakeTimers()
  • beforeEach
    中清除形状,在
    afterEach
    中销毁实例
  • 测试形状/工具时在
    packages/tldraw
    中进行
  • 使用
    expectToBeIn()
    进行状态机断言
  • 使用
    toMatchObject()
    进行部分匹配
  • 使用
    toCloselyMatchObject()
    处理浮点数
  • 使用
    vi.spyOn()
    进行模拟并务必调用
    mockRestore()