Marcus Wood

AboutProductsBlog

Effective Slate Testing Using React Testing Library

#slate#typescript

A Brief Background

About two years ago, I was consulting for a large client that needed a rich text editor built. I knew I was in store for an adventure, but I had no idea just how far down the rabbit hole I'd need to go. Fast forward to today and I've built two completely different rich text editors using Slate.

Throughout my time pouring over the Slate source, building editors, and spending more time than I'd like to admit trying to understand Cannot resolve a DOM node from Slate node errors, I've learned what makes working with Slate so hard: Testing and understanding.

A Quick Plug

We're going to tackle the first one today with the announcement of an open sourced Slate Test Utils package! This is something I've used for over a year on projects to help me write stable Slate.

As for understanding, Slate is a tough library to learn. My hope is the testing utils will help and depending on reception from the community I am thinking of making a Slate course that will explain the source in detail and help folks understand. If you're interested please sign up to the Slate newsletter!

Back to adventures in Slate!

office space slate

Kidding aside, I need to say how amazing and novel Slate is. Being able to do surgical treelike operations on a schemaless core while maintaining a selection in the tree is astounding. It has always been able to handle even the most ambitious features I've thrown at it. The only achilles heel I can see is it uses contenteditable, just like ProseMirror, Quill, and other alternatives (although Slate core is separated so it's well positioned for the future). And that is where our story begins...

ContentEditable

Content editable is a API from back in the early 2000s and up until this day there is not a firm spec on how it works. If you look up the spec on W3 you'll see things like this:

content editable lol

It's not ideal, and there's probably good reasons for that. **Puts on tin foil hat.** The only non contenteditable editor I know of on the internet is Google Docs (and maybe Microsoft Office 365). Do they really have an incentive to make this API better since they've already cracked on the code on building an editor without? Anyways, a story for another time.

If you want a better background, Marijn Haverbeke did a fantastic talk on this exact subject talking about ProseMirror (an alternative to Slate).

What does all of this have to do with testing? In order to write effective tests you have to understand the internals of Slate and a grasp of contenteditable.

Testing

Right now there is a few ways to test Slate that I know of:

  • Unit tests: You can test the internals of Slate core with unit tests
  • End to end tests: You can use something like Cypress to test an editor, but it's not without bumps.

The reason that you can't test Slate in React Testing Library like you do your other code is because it relies on a runner (like Jest or Mocha) to execute the tests. That test runner relies on mocking out the DOM in some shape or form. Most of the time this is transparent to you, but internally it's JSDOM.

JSDOM does not support contenteditable at the time of writing and who could blame them given how complicated and inconsistent the API is to work with. There are developments in this area so hopefully something changes soon!

Just because JSDOM doesn't support it doesn't mean we can't help it support just enough for our needs. If you look at the source of Slate-React, it handles a bunch of inpu events for you to where we don't need full contenteditable support, we just need enough to work the internals of Slate-React to make sure our editor is working as expected.

My Testing Wishlist

I looked everywhere for an answer to my testing woes and couldn't find it. I was looking for the following things:

  • 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

Unpeeling the Onion

Seeing that gave me an idea, what if I added the methods to JSDOM and also made Slate think we were in a browser environment? But how to add them? There's two missing:

  1. getTargetRanges
  2. DataTransfer

Admittedly, I have no idea what I'm doing when it comes to JSDOM. I never thought I'd be journeying to these depths, but I knew if I could get this to work then it be a gamechanger.

  • Higher confidence of deploying a high quality user experience
  • Lasting documentation of my intent for features
  • Rapid development and the ability to TDD complicated features
  • The ability to test nodes in isolation
  • The ability to have tests to protect my code when upgrading Slate (it's still in beta but hopefully these utils can help change that 🚀)
  • Emulate operating systems and test editor variants
  • Test normalization

So I kept poking and saw that JSDOM uses webidl files to generate these mocked APIs that we use. So I continued my journey down, this time to the source code of Firefox. I felt like this:

Sure enough, I plugged those bad boys into JSDOM, hit generate, and it worked first try! Would have never expected such, big props to the JSDOM team.

Slate Test Utils

With all that in place, I wrote my first test and it worked. It really worked. Testing my Slate editor with React Testing Library using Jest and JSDOM. What a time to be alive!

Slate Test Utils is available today for use in Slate projects everywhere. The rest of this blog is going to share with you some of the big benefits you get. Limitations of this approach are explained on the home page.

Hyperscript First Approach

Slate uses hyperscript to generate editor singletons on the fly that makes it super easy to reason about what a test should do. I've extended that approach for this testing library so that every test you write uses hyperscript. You create this yourself so it supports any kind of node in your editor!

Slate uses the singleton pattern. When you call createEditor() it creates a persistent object in memory. That's also why you memoize it in React const editor = useMemo(() => createEditor(), [])

const input = (
<editor>
<hp>
<htext>
<cursor />
</htext>
</hp>
</editor>
)

Strap in for Safety

The core of the API is the buildTestHarness function. It takes in your editor, renders it with React Testing Library and returns a bunch of helpful commands for testing.

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

It has a bunch of config options to so you should be able to render any sort of editor component you have without problem.

Conversational Commands

All of the commands for the harness are conversational in that you can phoenetically describe what the test is doing.

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

Atomic Assertions

Since we're rendering editor singletons and augmenting them using the internals of Slate-React and Slate. The entire editor singleon can be asserted on. This includes editor.selection, editor.children, editor.marks, and more. I've added a helper that lets you assert like this which checks both the selection and children.

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

Surgical Selections

Consistent with Slate's hyperscript, both collapsed and expanded selections are supported. This automatically creates an editor with a given selection, perfect for mocking out user interactions when their selection is across nodes.

const input = (
<editor>
<hp>
<htext>
potato
<cursor />
</htext>
</hp>
</editor>
)
const input = (
<editor>
<hp>
<htext>po</htext>
<htext italic>
<anchor />
tat
<focus />
</htext>
<htext>o</htext>
</hp>
</editor>
)

Beautiful Diffs

Since Slate ouputs JSON we get to leverage the differ there to give us beatiful errors when a test fails.

Let's walk through a couple examples:

it('user triggers bold hotkey and types with a collapsed selection', async () => {
const input = (
<editor>
<hp>
<htext>
potato
<cursor />
</htext>
</hp>
</editor>
)
const [editor, { triggerKeyboardEvent, type }] = await buildTestHarness(
RichTextExample,
)({
editor: input,
})
await triggerKeyboardEvent('mod+b')
// NOTE THE SPACE!
await type(' cucumbers')
assertOutput(
editor,
<editor>
<hp>
<htext>potato</htext>
<htext bold>
cucumbers
<cursor />
</htext>
</hp>
</editor>,
)
})

Would fail and show us this:

If we forgot to add bold, or something broke in our code this would happen:

it('user triggers bold hotkey and types with a collapsed selection', async () => {
const input = (
<editor>
<hp>
<htext>
potato
<cursor />
</htext>
</hp>
</editor>
)
const [editor, { triggerKeyboardEvent, type }] = await buildTestHarness(
RichTextExample,
)({
editor: input,
})
await triggerKeyboardEvent('mod+b')
await type(' cucumbers')
assertOutput(
editor,
<editor>
<hp>
<htext>potato</htext>
<htext>
cucumbers
<cursor />
</htext>
</hp>
</editor>,
)
})

The same goes for selection as well.

it('user triggers bold hotkey and types with a collapsed selection', async () => {
const input = (
<editor>
<hp>
<htext>
potato
<cursor />
</htext>
</hp>
</editor>
)
const [editor, { triggerKeyboardEvent, type }] = await buildTestHarness(
RichTextExample,
)({
editor: input,
})
await triggerKeyboardEvent('mod+b')
await type(' cucumbers')
assertOutput(
editor,
<editor>
<hp>
<htext>potato</htext>
<htext bold>
{' '}
cucumber
<cursor />s
</htext>
</hp>
</editor>,
)
})

The test utils are just as precise as Slate (since it uses it 🙏) giving you the ability to sandbox and test as many variations needed to deliver great UX.

Test Variants and Simulate OSes

Out of the box, Slate Test Utils provides a way for you to simulate the operating system by mocking the user agent. This is helpful for things like keyboard shortcuts or OS specific functionality. You can take it a step further and test variants as well.

For example, you could have one editor with two variants: comment and wordProcessor. You'd like to test both with the same test across operating systems. Not a problem!

const testCases = (variant?: 'comment' | 'wordProcessor') => {
it('user presses bold and types', async () => {
const input = (
<editor>
<hp>
<htext>
<cursor />
</htext>
</hp>
</editor>
)
const [editor, { type }, { getByTestId }] = await buildTestHarness(
RichTextExample,
)({
editor: input,
componentProps: { variant },
})
const editorElement = getByTestId('slate-content-editable')
// Whoop! We run the tests for all of our variants in
// one quick step.
expect(editorElement).toHaveAttribute('data-variant', variant)
// It's control in windows land so this fails!!
await type('banana')
const output = (
<editor>
<hp>
<htext>
banana
<cursor />
</htext>
</hp>
</editor>
) as unknown as Editor
expect(editor.children).toEqual(output.children)
expect(editor.selection).toEqual(output.selection)
})
}
const runVariants = () => {
describe.each([
['Comment', 'comment'],
['Word Processor', 'wordProcessor'],
])('%s', testCases)
}
testRunner(runVariants)

Other features

Slate test utils has a ton of other features like snapshot testing, strict validation, and more. It's also written in TypeScript!

Is it Too Good to be True?

All that glitters is not gold I'm afraid. There are limitations to this approach that I detail on the repo since it's not true contenteditable. However, I think this should cover around 90% of your testing needs. I've written over 500 user centric tests using this approach and it's been a gamechanger for me when I write Slate.

User centric testing you say? Being heavily influenced by Kent C. Dodds on his testing approach I've started to write tests with user... and let the markup in the test describe the intent to the developer. It's been a big help for things like Slate testing to cover all the edge cases and to have living user flows in the tests. I'll write a blog on it!

Want to Learn More?

I'm thinking about making a Slate course detailing everything I've learned showcasing patterns I've seen pay off big time as you scale your team and editor. If you're interested please sign up to the Slate newsletter!

Want more content like this?

Sign up to receive my newsletter, where I feature early access to new products, exciting content, and more!

Marcus Wood is a software engineer that focuses on building products using Typescript, React, and GraphQL. He has built and delivered solutions for some of the largest companies in the world.

Contact

Newsletter