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.
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!
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...
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:
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
.
Right now there is a few ways to test Slate that I know of:
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.
I looked everywhere for an answer to my testing woes and couldn't find it. I was looking for the following things:
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:
getTargetRanges
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.
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.
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.
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 Reactconst editor = useMemo(() => createEditor(), [])
const input = (<editor><hp><htext><cursor /></htext></hp></editor>)
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.
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 navconst unorderedList = getByTestId('bulleted-list')fireEvent.mouseDown(unorderedList)await type('🥕')await deleteBackward()await type('Carrots')
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>,)
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>)
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.
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 Editorexpect(editor.children).toEqual(output.children)expect(editor.selection).toEqual(output.selection)})}const runVariants = () => {describe.each([['Comment', 'comment'],['Word Processor', 'wordProcessor'],])('%s', testCases)}testRunner(runVariants)
Slate test utils has a ton of other features like snapshot testing, strict validation, and more. It's also written in TypeScript!
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!
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!
Sign up to receive my newsletter, where I feature early access to new products, exciting content, and more!