write-unit-tests

Par tldraw · tldraw

Rédaction de tests unitaires et d'intégration pour le SDK tldraw. À utiliser lors de la création de nouveaux tests, de l'ajout de couverture de tests ou de la correction de tests en échec dans `packages/editor` ou `packages/tldraw`. Couvre les patterns Vitest, l'utilisation de `TestEditor` et l'organisation des fichiers de test.

npx skills add https://github.com/tldraw/tldraw --skill write-unit-tests

Écrire des tests

Les tests unitaires et d'intégration utilisent Vitest. Les tests s'exécutent depuis les répertoires des espaces de travail, pas à la racine du dépôt.

Emplacements des fichiers de test

Tests unitaires - à côté des fichiers source :

packages/editor/src/lib/primitives/Vec.ts
packages/editor/src/lib/primitives/Vec.test.ts  # Même répertoire

Tests d'intégration - dans le répertoire src/test/ :

packages/tldraw/src/test/SelectTool.test.ts
packages/tldraw/src/test/commands/createShape.test.ts

Tests de formes/outils - à côté de l'implémentation :

packages/tldraw/src/lib/shapes/arrow/ArrowShapeUtil.test.ts
packages/tldraw/src/lib/shapes/arrow/ArrowShapeTool.test.ts

Quel espace de travail tester

  • packages/editor : Primitives principales, géométrie, gestionnaires, fonctionnalités de base de l'éditeur
  • packages/tldraw : Tout ce qui nécessite les formes/outils par défaut (la plupart des tests d'intégration)
cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "SelectTool"

TestEditor vs Editor

Utilisez TestEditor pour les tests d'intégration (inclut les formes/outils par défaut) :

import { createShapeId } from '@tldraw/editor'
import { TestEditor } from './TestEditor'

let editor: TestEditor

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

afterEach(() => {
    editor?.dispose()
})

Utilisez Editor brut pour tester la configuration de l'éditeur ou des configurations personnalisées :

import { Editor, createTLStore } from '@tldraw/editor'

beforeEach(() => {
    editor = new Editor({
        shapeUtils: [CustomShape],
        bindingUtils: [],
        tools: [CustomTool],
        store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
        getContainer: () => document.body,
    })
})

Méthodes courantes de TestEditor

// Simulation de pointeur
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?)

// Simulation de clavier
editor.keyDown(key, options?)
editor.keyUp(key, options?)

// Assertions d'état
editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.crop.idle')

// Assertions de formes
editor.expectShapeToMatch({ id, x, y, props: { ... } })

// Opérations sur les formes
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()

// Opérations sur les outils
editor.setCurrentTool('arrow')
editor.getCurrentToolId()

// Annuler/Rétablir
editor.undo()
editor.redo()

Options des événements de pointeur

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',
})

Modèles de configuration

Configuration standard avec IDs de formes

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()
})

Props réutilisables

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 } },
])

Fonctions helper

function arrow(id = ids.arrow1) {
    return editor.getShape(id) as TLArrowShape
}

function bindings(id = ids.arrow1) {
    return getArrowBindings(editor, arrow(id))
}

Mock avec vi.spyOn

// Mock d'une valeur de retour
vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)

// Mock d'une implémentation
const isHiddenSpy = vi.spyOn(editor, 'isShapeHidden')
isHiddenSpy.mockImplementation((shape) => shape.id === ids.hiddenShape)

// Vérifier les appels
const spy = vi.spyOn(editor, 'setSelectedShapes')
editor.selectAll()
expect(spy).toHaveBeenCalled()
expect(spy).not.toHaveBeenCalled()

// Toujours restaurer
isHiddenSpy.mockRestore()

Faux minuteurs

vi.useFakeTimers()

// Mock requestAnimationFrame
window.requestAnimationFrame = (cb) => setTimeout(cb, 1000 / 60)
window.cancelAnimationFrame = (id) => clearTimeout(id)

it('handles animation', () => {
    editor.alignShapes(editor.getSelectedShapeIds(), 'right')
    vi.advanceTimersByTime(1000)
    // Assertion après la fin de l'animation
})

Assertions

Correspondance de formes

// Correspondance partielle (la plus courante)
expect(editor.getShape(id)).toMatchObject({
    type: 'geo',
    x: 100,
    props: { w: 100 },
})

editor.expectShapeToMatch({
    id: ids.box1,
    x: 350,
    y: 350,
})

// Correspondance de virgule flottante (matcher personnalisé)
expect(result).toCloselyMatchObject({
    props: { normalizedAnchor: { x: 0.5, y: 0.75 } },
})

Assertions sur les tableaux

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)

Assertions d'état

editor.expectToBeIn('select.idle')
editor.expectToBeIn('select.brushing')
editor.expectToBeIn('select.crop.idle')

Tester l'annulation/rétablissement

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)
})

Tester les types 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()
})

Tester les formes personnalisées

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() {}
}

Tester les effets secondaires

beforeEach(() => {
    editor = new TestEditor()
    editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
        if (prev.croppingShapeId !== next.croppingShapeId) {
            // Traiter le changement d'état
        }
    })
})

Tester les événements

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' }))
})

Chaînage de méthodes

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()

Exécuter les tests

cd packages/tldraw && yarn test run
cd packages/tldraw && yarn test run --grep "arrow"
cd packages/editor && yarn test run --grep "Vec"

# Mode watch
cd packages/tldraw && yarn test

Résumé des motifs clés

  • Utilisez createShapeId() pour les IDs de formes
  • Utilisez vi.useFakeTimers() pour les comportements dépendant du temps
  • Nettoyez les formes dans beforeEach, disposez dans afterEach
  • Testez dans packages/tldraw pour les formes/outils
  • Utilisez expectToBeIn() pour les assertions de machine d'état
  • Utilisez toMatchObject() pour les correspondances partielles
  • Utilisez toCloselyMatchObject() pour les valeurs de virgule flottante
  • Mockez avec vi.spyOn() et toujours mockRestore()

Skills similaires