Adds test readme, updates test utils.

This commit is contained in:
Steve Ruiz 2021-07-22 11:10:02 +01:00
parent 87b46b7d31
commit 854bfaf6ed
3 changed files with 505 additions and 201 deletions

216
__tests__/README.md Normal file
View file

@ -0,0 +1,216 @@
# Testing Guide
Writing tests for tldraw? This guide will get you started.
- [Getting Started](#getting-started)
- [How to Test](#how-to-test)
- [What to Test](#what-to-test)
- [TestUtils](#test-utils)
## Getting Started
This project uses Jest for its unit tests.
- To run the test suite, run `yarn test` in your terminal.
- To start the test watcher, run `yarn test:watch`.
- To update the test snapshots, run `yarn test:update`.
Tests live inside of the `__tests__` folder.
To create a new test, create a file named `myTest.test.ts` inside of the `__tests__` folder.
## How to Test
In tldraw, we write our tests against application's _state_.
Remember that in tldraw, user interactions send _events_ to the app's _state_, where they produce a change (or not) depending on the state's configuration and current status. To test a feature, we "manually" send those same events to the state machine and then check whether the events produced the change we expected.
To test a feature, we'll need to:
- learn how the features works in tldraw
- identify the events involved
- identify the outcome of the events
- reproduce the events in our test
- test the outcome
### Example
Let's say we want to test the "create a page" feature of the app.
We'd start by creating a new file named `create-page.test.ts`. Here's some boilerplace to get us started.
```ts
// __tests__/create-page.test.ts
import TestState from '../test-utils'
const tt = new TestState()
it('creates a new page', () => {})
```
In the code above, we import our `TestState` class, create a new instance for this test file, then have a unit test that will assert something about our app's behavior.
In the app's UI, we can find a button labelled "create page".
```tsx
// page-panel.tsx
<DropdownMenuButton onSelect={() => state.send('CREATED_PAGE')} />
```
Because we're only testing the state machine, we don't have to worry about how the `DropdownMenuButton` component works, or whether `onSelect` is implemented correctly. Instead, our only concern is the call to `state.send`, where we "send" the `CREATED_PAGE` event to the app's central state machine.
Back in our test, we can send that the `CREATED_PAGE` event ourselves and check whether it's produced the correct outcome.
```ts
// __tests__/create-page.test.ts
import TestState from '../test-utils'
const tt = new TestState()
it('creates a new page', () => {
const pageCountBefore = Object.keys(tt.state.data.document.pages).length
tt.state.send('CREATED_PAGE')
const pageCountAfter = Object.keys(tt.state.data.document.pages).length
expect(pageCountAfter).toEqual(pageCountBefore + 1)
})
```
If we run our tests (with `yarn test`) or if we're already in watch mode (`yarn test:watch`) then our tests should update. If it worked, hooray! Now try to make it fail and see what that looks like, too.
## What to Test
While a test like "create a page" is pretty self-explanatory, most features are at least a little complex.
To _fully_ test a feature, we would need to:
- test the entire outcome
- testing every circumstance under which the outcome could be different
Let's take another look at the `CREATED_PAGE` event.
If we search for the event in the state machine itself, we can find where and how event is being handled.
```ts
// state/state.ts
ready: {
on: {
// ...
CREATED_PAGE: {
unless: ['isReadOnly', 'isInSession'],
do: 'createPage',
}
}
}
```
Here's where we can see what exactly we need to test. The event can tell us a few things:
- It should only run when the "ready" state is active
- It never run when the app is in read only mode
- It should never run when the app is in a session (like drawing or rotating)
These are all things that we could test. For example:
```ts
// __tests__/create-page.test.ts
import TestState from '../test-utils'
const tt = new TestState()
it('does not create a new page in read only mode', () => {
tt.state.send('TOGGLED_READ_ONLY')
expect(tt.state.data.isReadOnly).toBe(true)
const pageCountBefore = Object.keys(tt.state.data.document.pages).length
tt.state.send('CREATED_PAGE')
const pageCountAfter = Object.keys(tt.state.data.document.pages).length
expect(pageCountAfter).toEqual(pageCountBefore)
})
```
> Note that we're using a different event, `TOGGLED_READ_ONLY`, in order to get the state into the correct condition to make our test. When using events like this, it's a good idea to assert that the state is how you expect it to be before you make your "real" test. Here that means testing that the state's `data.isReadOnly` boolean is `true` before we test the `CREATED_PAGE` event.
We can also look at the `createPage` action.
```ts
// state/state.ts
createPage(data) {
commands.createPage(data, true)
},
```
If we follow this call, we'll find the `createPage` command (`state/commands/create-page.ts`). This command is more complex, but it gives us more to test:
- did we correctly iterate the numbers in the new page's name?
- did we get the correct child index for the new page?
- did we save the current page to local storage?
- did we save the new page to local storage?
- did we add the new page to the document?
- did we add the new page state to the document?
- did we go to the new page?
- when we undo the command, will we remove the new page / page state?
- when we redo the command, will we put the new page / page state back?
To _fully_ test a feature, we'll need to write tests that cover all of these.
### Todo Tests
...but while full test coverage is a goal, it's not always within reach. If you're not able to test everything about a feature, it's a good idea to write "placeholders" for the tests that need to be written.
```ts
describe('when creating a new page...', () => {
it('sets the correct child index for the new page', () => {
// TODO
})
it('sets the correct name for the new page', () => {
// TODO
})
it('saves the document to local storage', () => {
// TODO
})
})
```
### Snapshots
An even better way to improve coverage when dealing with complex tests is to write "snapshot" tests.
```ts
describe('when creating a new page...', () => {
it('updates the document', () => {
tt.state.send('CREATED_PAGE')
expect(tt.state.data).toMatchSnapshot()
})
})
```
While snapshot tests don't assert specific things about a feature's implementation, they will at least help flag changes in other parts of the app that might break the feature. For example, if we accidentally made the app start in read only mode, then the snapshot outcome of `CREATED_PAGE` would be different—and the test would fail.
## TestUtils
While you can test every feature in tldraw by sending events to the state, the `TestUtils` class is designed to make certain things easier. By convention, I'll refer to an instance of the `TestUtils` class as `tt`.
```ts
import TestState from '../test-utils'
const tt = new TestState()
```
The `TestUtils` instance wraps an instance of the app's state machine (`tt.state`). It also exposes the state's data as `tt.data`, as well as the state's helper methods (`tt.send`, `tt.isIn`, etc.)
- `tt.resetDocumentState` will clear the document and reset the app state.
- `tt.createShape` will create a new shape on the page.
- `tt.clickShape` will click a the indicated shape
Check the `test-utils.ts` file for the rest of the API. Feel free to add your own methods if you have a reason for doing so.

View file

@ -21,16 +21,133 @@ interface PointerOptions {
}
class TestState {
state: State
_state: State
snapshot: Data
constructor() {
this.state = _state
this._state = _state
this.state.send('TOGGLED_TEST_MODE')
this.snapshot = deepClone(this.state.data)
this.reset()
}
/**
* Get the underlying state-designer state.
*
* ### Example
*
*```ts
* tt.state
*```
*/
get state(): State {
return this._state
}
/**
* Get the state's current data.
*
* ### Example
*
*```ts
* tt.data
*```
*/
get data(): Readonly<Data> {
return this.state.data
}
/* -------- Reimplemenation of State Methods -------- */
/**
* Send a message to the state.
*
* ### Example
*
*```ts
* tt.send("MOVED_TO_FRONT")
*```
*/
send(eventName: string, payload?: unknown): TestState {
this.state.send(eventName, payload)
return this
}
/**
* Check whether a state node is active. If multiple names are provided, then the method will return true only if ALL of the provided state nodes are active.
*
* ### Example
*
*```ts
* tt.isIn("ready") // true
* tt.isIn("ready", "selecting") // true
* tt.isInAny("ready", "notReady") // false
*```
*/
isIn(...ids: string[]): boolean {
return this.state.isIn(...ids)
}
/**
* Check whether a state node is active. If multiple names are provided, then the method will return true if ANY of the provided state nodes are active.
*
* ### Example
*
*```ts
* tt.isIn("ready") // true
* tt.isIn("ready", "selecting") // true
* tt.isInAny("ready", "notReady") // true
*```
*/
isInAny(...ids: string[]): boolean {
return this.state.isInAny(...ids)
}
/**
* Check whether the state can handle a certain event (and optionally payload). The method will return true if the event is handled by one or more currently active state nodes and if the event will pass its conditions (if present) in at least one of those handlers.
*
* ### Example
*
*```ts
* example
*```
*/
can(eventName: string, payload?: unknown): boolean {
return this.state.can(eventName, payload)
}
/* -------------------- Specific -------------------- */
/**
* Save a snapshot of the state's current data.
*
* ### Example
*
*```ts
* tt.save()
* tt.restore()
*```
*/
save(): TestState {
this.snapshot = deepClone(this.data)
return this
}
/**
* Restore the state's saved data.
*
* ### Example
*
*```ts
* tt.save()
* tt.restore()
*```
*/
restore(): TestState {
this.state.forceData(this.snapshot)
return this
}
/**
* Reset the test state.
*
@ -65,20 +182,6 @@ class TestState {
return this
}
/**
* Send a message to the state.
*
* ### Example
*
*```ts
* tt.send("MOVED_TO_FRONT")
*```
*/
send(eventName: string, payload?: unknown): TestState {
this.state.send(eventName, payload)
return this
}
/**
* Create a new shape on the current page. Optionally provide an id.
*
@ -100,144 +203,6 @@ class TestState {
return this
}
/**
* Get the sorted ids of the page's children.
*
* ### Example
*
*```ts
* tt.getSortedPageShapes()
*```
*/
getSortedPageShapeIds(): string[] {
return Object.values(
this.data.document.pages[this.data.currentPageId].shapes
)
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
}
/**
* Get shapes for the current page.
*
* ### Example
*
*```ts
* tt.getShapes()
*```
*/
getShapes(): Shape[] {
return Object.values(
this.data.document.pages[this.data.currentPageId].shapes
)
}
/**
* Get whether the provided ids are the current selected ids. If the `strict` argument is `true`, then the result will be false if the state has selected ids in addition to those provided.
*
* ### Example
*
*```ts
* tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'])
* tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'], true)
*```
*/
idsAreSelected(ids: string[], strict = true): boolean {
const selectedIds = tld.getSelectedIds(this.data)
return (
(strict ? selectedIds.length === ids.length : true) &&
ids.every((id) => selectedIds.includes(id))
)
}
get selectedIds(): string[] {
return tld.getSelectedIds(this.data)
}
/**
* Get whether the shape with the provided id has the provided parent id.
*
* ### Example
*
*```ts
* tt.hasParent('childId', 'parentId')
*```
*/
hasParent(childId: string, parentId: string): boolean {
return tld.getShape(this.data, childId).parentId === parentId
}
/**
* Get the only selected shape. If more than one shape is selected, the test will fail.
*
* ### Example
*
*```ts
* tt.getOnlySelectedShape()
*```
*/
getOnlySelectedShape(): Shape {
const selectedShapes = tld.getSelectedShapes(this.data)
return selectedShapes.length === 1 ? selectedShapes[0] : undefined
}
/**
* Assert that a shape has the provided type.
*
* ### Example
*
*```ts
* tt.example
*```
*/
assertShapeType(shapeId: string, type: ShapeType): boolean {
const shape = tld.getShape(this.data, shapeId)
if (shape.type !== type) {
throw new TypeError(
`expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead`
)
}
return true
}
/**
* Assert that the provided shape has the provided props.
*
* ### Example
*
*```
* tt.assertShapeProps(myShape, { point: [0,0], style: { color: ColorStyle.Blue } } )
*```
*/
assertShapeProps<T extends Shape>(
shape: T,
props: { [K in keyof Partial<T>]: T[K] }
): boolean {
for (const key in props) {
let result: boolean
const value = props[key]
if (Array.isArray(value)) {
result = deepCompareArrays(value, shape[key] as typeof value)
} else if (typeof value === 'object') {
const target = shape[key] as typeof value
result =
target &&
Object.entries(value).every(([k, v]) => target[k] === props[key][v])
} else {
result = shape[key] === value
}
if (!result) {
throw new TypeError(
`expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead`
)
}
}
return true
}
/**
* Click a shape.
*
@ -589,6 +554,20 @@ class TestState {
return this
}
/**
* Select all shapes.
*
* ### Example
*
*```ts
* tt.deselectAll()
*```
*/
selectAll(): TestState {
this.state.send('SELECTED_ALL')
return this
}
/**
* Deselect all shapes.
*
@ -604,7 +583,7 @@ class TestState {
}
/**
* Delete the selected shapes
* Delete the selected shapes.
*
* ### Example
*
@ -617,36 +596,6 @@ class TestState {
return this
}
/**
* Get a shape and test it.
*
* ### Example
*
*```ts
* tt.testShape("myShapeId", myShape => myShape )
*```
*/
testShape<T extends Shape>(
id: string,
fn: (shape: T, shapeUtils: ShapeUtility<T>) => boolean
): boolean {
const shape = this.getShape<T>(id)
return fn(shape, shape && getShapeUtils(shape))
}
/**
* Get a shape
*
* ### Example
*
*```ts
* tt.getShape("myShapeId")
*```
*/
getShape<T extends Shape>(id: string): T {
return tld.getShape(this.data, id) as T
}
/**
* Undo.
*
@ -675,46 +624,181 @@ class TestState {
return this
}
/* ---------------- Getting Data Out ---------------- */
/**
* Save a snapshot of the state's current data.
* Get a shape by its id. Note: the shape must be in the current page.
*
* ### Example
*
*```ts
* tt.save()
* tt.getShape("myShapeId")
*```
*/
save(): TestState {
this.snapshot = deepClone(this.data)
return this
getShape<T extends Shape>(id: string): T {
return tld.getShape(this.data, id) as T
}
/**
* Restore the state's saved data.
* Get the current selected ids.
*
* ### Example
*
*```ts
* tt.save()
* tt.restore()
* example
*```
*/
restore(): TestState {
this.state.forceData(this.snapshot)
return this
get selectedIds(): string[] {
return tld.getSelectedIds(this.data)
}
/**
* Get the state's current data.
* Get shapes for the current page.
*
* ### Example
*
*```ts
* tt.data
* tt.getShapes()
*```
*/
get data(): Readonly<Data> {
return this.state.data
getShapes(): Shape[] {
return Object.values(
this.data.document.pages[this.data.currentPageId].shapes
)
}
/**
* Get ids of the page's children sorted by their child index.
*
* ### Example
*
*```ts
* tt.getSortedPageShapes()
*```
*/
getSortedPageShapeIds(): string[] {
return this.getShapes()
.sort((a, b) => a.childIndex - b.childIndex)
.map((shape) => shape.id)
}
/**
* Get the only selected shape. If more than one shape is selected, the test will fail.
*
* ### Example
*
*```ts
* tt.getOnlySelectedShape()
*```
*/
getOnlySelectedShape(): Shape {
const selectedShapes = tld.getSelectedShapes(this.data)
return selectedShapes.length === 1 ? selectedShapes[0] : undefined
}
/**
* Get whether the provided ids are the current selected ids. If the `strict` argument is `true`, then the result will be false if the state has selected ids in addition to those provided.
*
* ### Example
*
*```ts
* tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'])
* tt.idsAreSelected(state.data, ['rectangleId', 'ellipseId'], true)
*```
*/
idsAreSelected(ids: string[], strict = true): boolean {
const selectedIds = tld.getSelectedIds(this.data)
return (
(strict ? selectedIds.length === ids.length : true) &&
ids.every((id) => selectedIds.includes(id))
)
}
/**
* Get whether the shape with the provided id has the provided parent id.
*
* ### Example
*
*```ts
* tt.hasParent('childId', 'parentId')
*```
*/
hasParent(childId: string, parentId: string): boolean {
return tld.getShape(this.data, childId).parentId === parentId
}
/**
* Assert that a shape has the provided type.
*
* ### Example
*
*```ts
* tt.example
*```
*/
assertShapeType(shapeId: string, type: ShapeType): boolean {
const shape = tld.getShape(this.data, shapeId)
if (shape.type !== type) {
throw new TypeError(
`expected shape ${shapeId} to be of type ${type}, found ${shape?.type} instead`
)
}
return true
}
/**
* Assert that the provided shape has the provided props.
*
* ### Example
*
*```
* tt.assertShapeProps(myShape, { point: [0,0], style: { color: ColorStyle.Blue } } )
*```
*/
assertShapeProps<T extends Shape>(
shape: T,
props: { [K in keyof Partial<T>]: T[K] }
): boolean {
for (const key in props) {
let result: boolean
const value = props[key]
if (Array.isArray(value)) {
result = deepCompareArrays(value, shape[key] as typeof value)
} else if (typeof value === 'object') {
const target = shape[key] as typeof value
result =
target &&
Object.entries(value).every(([k, v]) => target[k] === props[key][v])
} else {
result = shape[key] === value
}
if (!result) {
throw new TypeError(
`expected shape ${shape.id} to have property ${key}: ${props[key]}, found ${key}: ${shape[key]} instead`
)
}
}
return true
}
/**
* Get a shape and test it.
*
* ### Example
*
*```ts
* tt.testShape("myShapeId", (myShape, utils) => expect(utils(myShape).getBounds()).toMatchSnapshot() )
*```
*/
testShape<T extends Shape>(
id: string,
fn: (shape: T, shapeUtils: ShapeUtility<T>) => boolean
): boolean {
const shape = this.getShape<T>(id)
return fn(shape, shape && getShapeUtils(shape))
}
/**

View file

@ -14,6 +14,10 @@ import { PlusIcon, CheckIcon } from '@radix-ui/react-icons'
import state, { useSelector } from 'state'
import { useEffect, useRef, useState } from 'react'
function handleCreatePage() {
state.send('CREATED_PAGE')
}
export default function PagePanel(): JSX.Element {
const rIsOpen = useRef(false)
const [isOpen, setIsOpen] = useState(false)
@ -76,7 +80,7 @@ export default function PagePanel(): JSX.Element {
))}
</DropdownMenu.RadioGroup>
<DropdownMenuDivider />
<DropdownMenuButton onSelect={() => state.send('CREATED_PAGE')}>
<DropdownMenuButton onSelect={handleCreatePage}>
<span>Create Page</span>
<IconWrapper size="small">
<PlusIcon />