A toolkit to test Slate rich text editors with Jest, React Testing Library, and hyperscript

Slate Test Utils

slate-test-utils-wave.mp4


Created with Wave Snippets

A toolkit to test Slate rich text editors with Jest, React Testing Library, and hyperscript! Write user centric integration tests with ease. Read the announcement.

  • ? Works with Jest, React Testing Library, and JSDOM (Create React App and Vite friendly)
  • ? Out of the box support for testing: typing, selection, keyboard events, beforeInput events, normalization, history, operations,
  • ? Stage editor state using Hyperscript instead of manual mocking or creating a Storybook story per state
  • ? Stage tests with a mocked collapsed, expanded, or reverse expanded selection
  • ✅ Supports any Slate React editor
  • ? Beautiful diffs on failing tests
  • ⚙️ Supports any number of nodes and custom data structures
  • ? Supports emulating Windows and Mac for OS specific testing
  • ? Conversational API that makes testing complex workflows easy
  • ? Test variants of your editor with the same test
  • ? Snapshot testing friendly (if you’re into that kinda thing)
  • ? Fully typed with TypeScript

Want to learn more about Slate? Join the newsletter.

Example

To see full examples go to example/.

/** @jsx jsx */

import { assertOutput, buildTestHarness } from '../../dist/esm'
import { RichTextExample } from './Editor'
import { jsx } from './test-utils'
import { fireEvent } from '@testing-library/dom'

it('user inserts an bulleted list with a few items', async () => {
  const input = (
    <editor>
      <hp>
        <htext>
          <cursor />
        </htext>
      </hp>
    </editor>
  )

  const [
    editor,
    { type, pressEnter, deleteBackward, triggerKeyboardEvent },
    { getByTestId },
  ] = await buildTestHarness(RichTextExample)({
    editor: input,
  })

  // Click the unordered list button in the nav
  const unorderedList = getByTestId('bulleted-list')
  fireEvent.mouseDown(unorderedList)

  await type('?')
  await deleteBackward()
  await type('Carrots')

  assertOutput(
    editor,
    <editor>
      <hbulletedlist>
        <hlistitem>
          <htext>
            Carrots
            <cursor />
          </htext>
        </hlistitem>
      </hbulletedlist>
    </editor>,
  )
})

Motivation

Rich text editors are hard. What makes them harder is being able to test them in a way the gives you confidence that your code works as expected. There’s so many user input mechanisms, edge cases, selection, state, normalization, and more to keep in mind when developing.

You could do an end to end testing framework, but even those aren’t without struggles, not to mention they’re slow and another piece of infrastructure to worry about. Additionally, mocking up every what if scenario becomes difficult because generating the test states takes time. Even if you manage to get it all set up, it’s hard to see the diff on your breaking tests unlike Jest that has a fantastic reporter for diffing JSON (what Slate state serializes to by default).

After trying, E2E tests, no tests (don’t recommend ?), and unit tests like Slate core nothing seemed to give me enough confidence and convenience that my was working as intended.

This is where the Slate Test Utils come in! It’s an abstraction that uses hyperscript to generate editor states that can be tested in a JSDOM environment with a bit of black magic.

My hope is that by providing a better way to test, everyone can deliver better editor experiences. I also hope that this helps get Slate-React to a stable 1.0 by providing a way to test it internally.

Testing ContentEditable in JSDOM?

It’s well documented that JSDOM does not support contenteditable, the API that Slate is built on top of. JSDOM is a mocked DOM that you run your tests again when using Jest. However, since Slate has done
an amazing job saving us from the darkness of working with contenteditable directly there’s an opportunity to test a large part of the internal Slate-React API and in turn, our code.

That opportunity is what this library takes advantage of. There’s some big limitations with this testing approach, but I would estimate that it has covered over 90% of my testing needs and has completely changed how I write Slate code.

Installation

The installation to make this work in your environment is going to be a ? bear, I apologize in advance. Test environments are always difficult to setup.

Prerequisites

Make sure you have Jest, React Testing Library, and React Testing Library DOM configured.

  1. Setup Jest
  2. Setup React Testing Library
  3. Add Patch Package

Install Slate Test Utils

yarn add -D slate-test-utils

# Or

npm install -D slate-test-utils

Now this is where the black magic comes into play. We need to patch your node_modules with some things that will make JSDOM play friendly with our test harness. Go to this repo and find the /patches folder and copy them into a /patches folder at the root of your repo. Once you have done that…

yarn install

# Or

npm install

That should apply your patches to your node_modules. You may get a warning if the versions mismatch, but long as you don’t get an error you are good to go. If you get an error you will need to manually create your own patches based off the ones in this repo.

Lastly, you need to add this line to your setupTests.js file for Jest so we can mock things.

import 'slate-test-utils/mocks'

// or if you are in commonjs

require('slate-test-utils/mocks')

Configuring Your Hyperscript

The schemaless core of Slate is truly amazing and is fully supported with slate-test-utils. Since we cannot know what your editor’s structure is like you need to configure your own hyperscript. Create a file called testUtils or slateTestUtils and fill out what your document looks like.

// @ts-ignore - Imports will be there from the upstream patch
import { createHyperscript, createText } from 'slate-hyperscript'
/**
 * This is the mapping for the JSX that creates editor state. Add to it as needed.
 * The h prefix isn't needed. It's added to be consistent and to let us know it's
 * hyperscript.
 */
export const jsx = createHyperscript({
  elements: {
    // Add any nodes here with any attributes that's required or optional
    hp: { type: 'paragraph' },
    hbulletedlist: { type: 'bulleted-list' },
    hlistitem: { type: 'list-item' },
    inline: { inline: true },
    block: {},
    wrapper: {},
  },
  creators: {
    htext: createText,
  },
})

Typescript

If you are using TypeScript you need to let the compiler know about your custom JSX types. Within your /src directory add a hyperscript.d.ts file.

declare namespace JSX {
  interface Element {}
  interface IntrinsicElements {
    hp: any
    editor: any
    htext: {
      // These optional params will show up in the autocomplete!
      bold?: boolean
      underline?: boolean
      italic?: boolean
      children?: any
    }
    hbulletedlist: any
    hlistitem: any
    cursor: any
    focus: any
    anchor: any
  }
}

Making your Editor Test Friendly

For this to work, your RichTextEditor component has to accept two props:

  • editor: This is an editor singleton that the test harness creates and passed into your editor. It’s what the hyperscript creates for you.
  • initialValue: This is the editor.children from the editor singleton.

Your editor call-site will look something like this to make it test friendly:

const emptyEditor: Descendant[] = [
  {
    type: 'paragraph',
    children: [{ text: '' }],
  },
]

export const RichTextExample: FC<{
  editor?: Editor
  initialValue?: Descendant[]
}> = ({
  editor: mockEditor,
  initialValue = emptyEditor,
}) => {
  // Starts with a default value same as usual except now we can stage
  // in one for our testing.
  const [value, setValue] = useState<Descendant[]>(initialValue)
  const editor = useMemo(() => createEditor(), [])

Last step, you need to add a data-testid to your Editable component.

  <Editable
    data-testid="slate-content-editable"

Testing

With your editor configured you should be good to go! Check out /example for a bunch of tests and patterns.

For all tests make sure you add this to the top:

/** @jsx jsx */

import { jsx } from '../test-utils'

The first line sets the pragma that will parse your hyperscript. The second line will import the pragma.

API

The test utils export a few methods that help you create user centric tests for your editor.

BuildTestHarness

A test harness for the RichTextEditor that adds custom queries to assert on, lots of simulated actions, and a custom rerender in case you want to assert on the DOM. In most cases, you’ll want to assert directly on the editor state to check that the editor selection and other pieces of the editor are working as intended.

Your first invocation of the test harness needs to be a React component.

const richTextHarness = buildTestHarness(RichTextExample)

Tip! You can partially apply the buildTestHarness function to create a bunch of test harnesses per
variant of your editor.

Next, you need to pass in the config to render that component. You must pass an editor anything else is optional. You are returned a tuple of props. The first is going to be the editor you passed into the harness. The second is going to be commands for testing. The third is custom queries for asserting and the bag of props from render in React Testing Library.

Config

Use these properties to customize the testHarness

/**
 * A Slate editor singleton.
 */
editor: any
/**
 * Pretty logs out all operations on the editor so you can see what's going on in tests.
 */
debug?: boolean
/**
 * Ensures Slate content is valid before rendering. This is not turned on by default
 * because you may want to test invalid states for normalization or testing purposes.
 *
 * @default false
 */
strict?: boolean
/**
 * Props you would like to pass down to the element you have passed in to test. This could be disabled states
 * variants, specific styles, or anything else!
 */
componentProps?: any

/**
 * The test ID for the Editable component that is used
 * to run the test harness.
 *
 * @default 'slate-content-editable'
 */
testID?: string

const [editor, { triggerKeyboardEvent, type }] = await buildTestHarness(
  RichTextExample,
)({
  editor: input,
})

Most of your call-sites will look like this:

const [editor, { triggerKeyboardEvent, type }] = await buildTestHarness(
  RichTextExample,
)({
  editor: input,
})

// Or this, same thing except with this you can reuse the first part of the function!
const richTextHarness = buildTestHarness(RichTextExample)

const [editor, { triggerKeyboardEvent, type }] = await richTextHarness({
  editor: input,
})

Commands

These commands are what you can use to interact with your rendered editor

type: (s: string) => Promise<void>
deleteForward: () => Promise<void>
deleteBackward: () => Promise<void>
deleteEntireSoftline: () => Promise<void>
deleteHardLineBackward: () => Promise<void>
deleteSoftLineBackward: () => Promise<void>
deleteHardLineForward: () => Promise<void>
deleteSoftLineForward: () => Promise<void>
deleteWordBackward: () => Promise<void>
deleteWordForward: () => Promise<void>
pressEnter: () => Promise<void>
/**
 * Use a hotkey combination from is-hotkey. See testHarness internals
 * for usage.
 */
triggerKeyboardEvent: (hotkey: string) => Promise<void>
typeSpace: () => Promise<void>
undo: () => Promise<void>
redo: () => Promise<void>
selectAll: () => Promise<void>
isApple: () => boolean
rerender: () => void

Queries

The third param is the bag of props returned from render. It includes some helper queries for Slate and all of the default methods returned from React Testing Library.

Test Runner

The test runner will run your tests simulated in iOS and Windows environments by mocking the user agent. This is useful for testing keyboard events and other OS specific functionality. Refer to example/src/tests/mac-windows.test.tsx for usage.

Running Example Folder

Run the example project to see it in action and get an idea of some fun patterns you can include in your testing.

git clone https://github.com/mwood23/slate-test-utils

cd slate-test-utils

yarn install && yarn build

cd example

yarn install

yarn test

Limitations

There are some big limitations to this approach when testing your editor. You will not be able to test 100% of the behavior of your editor with this framework, so manual testing or E2E tests will be needed depending on your use case.

  • Any contenteditable event that is not handled by your or Slate React will not work. For example, if you fire the key down arrowLeft, nothing will happen unless you handle that event specifically because contenteditable is not fully supported.
  • We are using our own jsx pragma to parse the tests so you will not be able to use React components in the same file. That’s the reason in the test harness we have a componentProps field that lets you put in any amount of custom props you need to test.
  • React 17.x will work, but if you use TypeScript, you may run into problems parsing your tests because of how it works. If you do, you will need to create a tsconfig specific for your tests with "jsx": "react".
  • Jest 26/27 are supported depending on your version. It is important to note that since we patch JSDOM, you need to make sure that the patch files will work.
  • Slate 0.70.0 is the only officially supported version although I have tested this all the way to version 0.59.0. Since Slate is beta, your mileage may vary. Please open an issue if you see anything weird.

Unknown Support

There is a lot to support with Slate. I’m not sure if these will work or not because I haven’t needed to use them often enough to know. Open to PRs to add this functionality or example usages!

  1. Copy-paste
  2. Void elements

Errors

  • If you get an error about DataTransfer not being defined then you haven’t imported the slate-test-utils mock correctly
  • If you get an error about your patch file make sure your versions are consistent with the /example file or create a patch specific to that version
  • If your editor does not appear to be updating from your tests make sure you have made your editor test friendly
  • If you get an error in your test about your hyperscript not being correct or un-parsable make sure you are importing the pragma and your built hyperscript

FAQ

  • Could I use this with ProseMirror? I suppose you could depending on how they handle their events under the hood.

TODO

  • PR the patches to the respective repos, especially the Slate ones.
  • Write tests in Slate-React using the test utils?

Contributing

Any and all PRs, issues, and ideas for improvement welcomes!

GitHub

View Github