Rich Text Editor Testing Guide
How to Test Slate Editor with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing Slate.js rich text editors with Playwright. Custom rendering layers, normalization rules, Transforms API calls, void elements, nested blocks, and the selection model that makes Slate uniquely powerful and uniquely difficult to test.
“Slate.js receives over five million weekly npm downloads and powers rich text editing in tools like Notion, Airtable, and GitBook, making it one of the most widely adopted editor frameworks in production.”
npmjs.com
Slate Editor Input Processing Flow
1. Why Testing Slate Editors Is Harder Than It Looks
Slate.js is not a drop-in textarea. It is a framework for building custom rich text editors, which means the DOM you see in the browser is entirely controlled by your application's rendering layer. Every element type, every leaf style, and every void node is rendered by a React component you define. There are no standard HTML form elements to target. A Playwright test cannot simply call page.fill() on a Slate editor because the editable surface is a contentEditable div, not an input or textarea.
The complexity starts with the rendering layer. Slate decouples the document model from the DOM entirely. The editor state is a tree of JSON nodes, and your renderElement and renderLeaf callbacks translate that tree into React components. The DOM structure bears no resemblance to a standard HTML editor. A paragraph might render as a <div>, a <p>, or a custom component with data attributes, depending on how the developer implemented it.
Then there is normalization. Slate runs normalization passes after every operation to enforce schema constraints. If your editor requires that every block must contain at least one text node, or that images can only appear inside figure elements, Slate will automatically restructure the document tree. A test that inserts content and immediately asserts the DOM state may fail because normalization has not yet finished transforming the tree.
Void elements add another layer. Components like image embeds, video players, and mention chips are marked as isVoid: true in Slate, which means they are not editable and have their own interaction model. You cannot type into them. Clicking them, selecting across them, and deleting them each require different approaches than regular text nodes. Nested blocks (like blockquotes containing lists containing paragraphs) create deep DOM hierarchies that are fragile under selection operations. And the Slate selection model uses its own Path and Point abstractions that do not map directly to browser Selection and Range objects, creating a mismatch between what the browser reports and what Slate considers selected.
Slate Document Model to DOM Pipeline
Editor Value
JSON node tree
Normalization
Schema enforcement
renderElement
Custom React components
renderLeaf
Inline formatting
DOM Output
contentEditable div
User Input
Keystrokes, paste, DnD
Transforms
Update JSON tree
Void Element Lifecycle
Insert Command
Transforms.insertNodes()
Normalization
Validate void schema
renderElement
Void wrapper + children
User Interaction
Click, select, delete
Selection Update
Path-based cursor
DOM Sync
React reconciliation
A reliable Slate test suite must account for all six of these layers: custom rendering, normalization timing, the Transforms API, void element behavior, nested block structures, and the selection model. The sections below address each one with runnable Playwright code.
2. Setting Up a Reliable Slate Test Environment
Testing Slate editors requires a working application that renders the editor. Unlike API testing, you need a real browser context because Slate relies heavily on browser input events (beforeinput, compositionstart, compositionend) that cannot be simulated with synthetic events. Playwright is the right tool because it fires real OS-level input events, not JavaScript synthetics.
Slate Test Environment Checklist
- Run the app with a known initial editor value (empty or fixture)
- Add data-testid attributes to the Slate editable container
- Add data-slate-node and data-slate-type attributes to custom elements
- Expose editor instance on window for debugging (dev mode only)
- Disable autosave debounce in test mode to avoid timing issues
- Set a known viewport size to avoid responsive layout changes
- Configure Playwright to use Chromium (best contentEditable support)
- Install slate, slate-react, and slate-history as peer dependencies
Playwright Configuration
Test Helpers for Slate
Slate editors need a small set of helper utilities. The most important is a function to locate the editable container and focus it, since many operations fail silently when the editor is not focused. You also need helpers to read the editor value from the browser context and to wait for normalization to complete.
Exposing the Editor for Test Introspection
To assert against the Slate document model (not just the DOM), expose the editor instance on window in development or test mode. This lets your Playwright tests read the authoritative state rather than parsing the rendered DOM.
3. Scenario: Basic Text Input and Formatting
The foundational scenario is typing text and applying inline formatting. This exercises the core Slate input pipeline: browser input event, Slate operation, normalization, React render. It sounds trivial, but it catches real issues. Slate processes keystrokes through the onDOMBeforeInputhandler, which maps browser input types to Slate operations. If your editor's event handlers interfere, or if a plugin overrides the default insert behavior, basic typing can break silently.
Basic Text Input and Bold Formatting
StraightforwardGoal
Type text into an empty Slate editor, apply bold formatting with a keyboard shortcut, verify the DOM renders a <strong> tag, and confirm the editor value contains the correct mark.
Preconditions
- Editor is rendered with an empty initial value
- Bold formatting is bound to Ctrl/Cmd+B
- Editor instance is exposed on
window.__SLATE_EDITOR__
Playwright Implementation
What to Assert Beyond the UI
- The Slate document model has the correct mark on the right text range
- Undo/redo stack contains the expected number of operations
- No normalization errors in the console
Basic Typing: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('type and bold text', async ({ page }) => {
await page.goto('/editor');
const editor = page.locator('[data-testid="slate-editor"]');
await editor.click();
await page.keyboard.type('Hello world');
const textNode = editor.locator('text=world');
await textNode.dblclick();
await page.keyboard.press('Meta+b');
await expect(editor.locator('strong')).toHaveText('world');
const value = await page.evaluate(() =>
JSON.parse(JSON.stringify(
(window as any).__SLATE_EDITOR__.children
))
);
expect(value[0].children[1].bold).toBe(true);
});4. Scenario: Nested Block Structures
Slate supports arbitrary nesting of block elements. A blockquote can contain a bulleted list, which contains list items, each containing a paragraph. This nesting is where most editor bugs live. Enter key behavior changes depending on depth. Backspace at the start of a nested block might unwrap one level, merge with the previous sibling, or lift the content entirely, depending on your normalization rules.
Testing nested blocks requires precise keyboard sequences and careful verification of the resulting tree structure. You cannot rely on visual inspection alone because two completely different document trees can render identically in the browser. A blockquote wrapping a paragraph looks the same as a blockquote wrapping a div wrapping a paragraph, but they behave differently under editing operations.
Nested List Inside Blockquote
ComplexGoal
Create a blockquote, add a bulleted list inside it, type items, then verify the document tree has the correct nesting structure and that pressing Enter at the end of the last item creates a new item (not a new blockquote).
Preconditions
- Editor supports blockquote and bulleted-list element types
- Toolbar buttons or keyboard shortcuts toggle block types
- Normalization ensures list items always contain at least one text child
Playwright Implementation
5. Scenario: Void Element Interactions
Void elements in Slate are nodes that cannot contain editable text. Images, video embeds, horizontal rules, mention chips, and code blocks with external renderers are common examples. Slate treats them differently from regular blocks: the cursor cannot be placed inside them, selection across them behaves differently, and deleting them requires special handling. The isVoid function in your editor plugin marks which element types are void.
Testing void elements is tricky because the browser's native selection model does not understand Slate's void concept. When you click an image in a Slate editor, Slate intercepts the click and sets its own selection to the path of the void node. The browser may report a different selection. Your tests need to verify both the visual state (the void element is visually selected, often with a blue ring or highlight) and the Slate model state (the selection path points to the void node).
Image Void Element: Insert, Select, Delete
ModerateGoal
Insert an image void element, click it to select it, verify the selection highlight appears, then delete it with the Backspace key and confirm the document no longer contains the image node.
Playwright Implementation
What to Assert Beyond the UI
- Slate selection path points to the void node, not a text node inside it
- Deleting the void node does not orphan adjacent empty paragraphs
- Normalization inserts an empty paragraph after the void if it is the last node
6. Scenario: Normalization Rules Under Stress
Normalization is Slate's constraint enforcement system. After every operation, Slate walks the document tree and calls your normalizeNode function on every node that was involved in the change. Your normalization rules might enforce that headings cannot be empty, that list items must always be children of a list wrapper, or that two adjacent text nodes with the same marks must be merged. These rules run silently, and they can produce surprising results when tested because the document state after an operation is not always what the operation alone would suggest.
The most common normalization pitfall in testing is the “empty block” rule. Many editors enforce that the document must always end with a paragraph. If you delete the last block, normalization inserts a new empty paragraph. Tests that assert editor.children.length === 0 after clearing the editor will always fail because normalization prevents an empty document.
Normalization Enforces Schema Constraints
ComplexGoal
Verify that normalization rules fire correctly: deleting all content produces a single empty paragraph, pasting a bare list item wraps it in a list, and adjacent identical marks merge into a single text node.
Playwright Implementation
7. Scenario: Selection Model and Cursor Behavior
Slate maintains its own selection model using Path, Point, and Range abstractions. A Path is an array of indices describing the position of a node in the tree (for example, [0, 2, 1] means the second child of the third child of the first top-level node). A Point is a Path plus an offset within the text of the leaf node. A Range is two Points (anchor and focus). This model is independent of the browser's native Selection and Range APIs, which operate on DOM nodes.
When testing selection, you need to verify both layers. The browser selection determines what the user sees highlighted. The Slate selection determines what operations like Transforms.delete or Editor.marks will act on. If the two get out of sync (a known class of Slate bugs), the user sees one thing selected but editing operations affect something else.
Selection Across Block Boundaries
ComplexGoal
Create multiple paragraphs, select a range that spans two blocks, verify the Slate selection covers both blocks, then delete the selection and confirm the blocks merge correctly.
Playwright Implementation
Selection Testing: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('cross-block selection and delete', async ({ page }) => {
await page.goto('/editor');
const editor = page.locator('[data-testid="slate-editor"]');
await editor.click();
await page.keyboard.type('First paragraph');
await page.keyboard.press('Enter');
await page.keyboard.type('Second paragraph');
await page.evaluate(() => {
const e = (window as any).__SLATE_EDITOR__;
const { Transforms } = require('slate');
Transforms.select(e, {
anchor: { path: [0, 0], offset: 6 },
focus: { path: [1, 0], offset: 7 },
});
});
await page.keyboard.press('Backspace');
const value = await page.evaluate(() =>
JSON.parse(JSON.stringify(
(window as any).__SLATE_EDITOR__.children
))
);
expect(value).toHaveLength(1);
});8. Scenario: Paste and Deserialization
Pasting content into a Slate editor triggers the deserialization pipeline. When a user pastes HTML, Slate (or your custom paste handler) must parse the HTML, convert it into Slate nodes, normalize the result, and insert it at the current selection. This is one of the most bug-prone areas in any rich text editor because the HTML on the clipboard can come from anywhere: Word, Google Docs, web pages, or another Slate editor. Each source produces wildly different markup.
Playwright can simulate paste events with real clipboard data using the page.evaluate approach to dispatch a ClipboardEvent with custom HTML in the DataTransfer. This is more reliable than keyboard.press('Meta+v') because it lets you control exactly what HTML reaches the editor's paste handler.
Paste HTML with Mixed Formatting
ModerateGoal
Paste an HTML fragment containing bold text, a link, and a list into the editor. Verify the Slate document model correctly deserializes each element type and that normalization cleans up any invalid structures.
Playwright Implementation
Paste Testing: Playwright vs Assrt
import { test, expect } from '@playwright/test';
test('paste HTML into editor', async ({ page }) => {
await page.goto('/editor');
const editor = page.locator('[data-testid="slate-editor"]');
await editor.click();
await page.evaluate((html) => {
const el = document.querySelector('[data-testid="slate-editor"]');
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', 'fallback');
const ev = new ClipboardEvent('paste', {
clipboardData: dt, bubbles: true, cancelable: true,
});
el!.dispatchEvent(ev);
}, '<p><strong>Bold</strong> and <a href="https://example.com">link</a></p>');
const value = await page.evaluate(() =>
JSON.parse(JSON.stringify(
(window as any).__SLATE_EDITOR__.children
))
);
expect(value[0].children.find((c: any) => c.bold)?.text).toBe('Bold');
});9. Common Pitfalls That Break Slate Test Suites
These pitfalls come from real GitHub issues and Stack Overflow questions filed by developers testing Slate editors in production. Each one can cause intermittent test failures that are painful to diagnose.
Slate Testing Anti-Patterns
- Using page.fill() on the editor (contentEditable ignores fill(); use keyboard.type() instead)
- Asserting DOM state immediately after an operation without waiting for React re-render
- Relying on CSS class names for element type detection instead of data-slate-type attributes
- Testing with Firefox or WebKit where contentEditable beforeinput events behave differently
- Assuming editor.children.length === 0 after clearing (normalization prevents empty documents)
- Using dispatchEvent with synthetic KeyboardEvents (Slate ignores them; use Playwright keyboard API)
- Forgetting to focus the editor before typing (Slate silently drops input when unfocused)
- Selecting text with mouse drag simulation instead of Transforms.select() for precision tests
Pitfall 1: page.fill() Does Not Work
The single most common mistake. Playwright's page.fill() method works by setting the value property of input and textarea elements. Slate's editable div has no value property. Calling fill() on it either throws or silently does nothing. You must use page.keyboard.type() which fires real input events that Slate processes through its onDOMBeforeInput handler. This is documented in Slate issue #3476.
Pitfall 2: React Render Timing
Slate updates its internal state synchronously, but React renders those changes asynchronously. If you read the DOM immediately after a Slate operation, you may see stale content. The workaround is to wait for the next animation frame or use Playwright's expect().toContainText() with auto-retry, which polls until the assertion passes or times out. Never use fixed page.waitForTimeout() delays.
Pitfall 3: Browser Differences with beforeinput
Slate's input handling relies heavily on the beforeinput event and its inputType property. Chromium, Firefox, and WebKit implement beforeinput differently. Firefox did not support all input types until recently (see the Slate compatibility tracking issue #5765). Run your Slate tests primarily in Chromium and add Firefox/WebKit only as a secondary check. If you see tests pass in Chromium but fail in Firefox, the issue is almost certainly a beforeinput event difference, not a bug in your test.
Pitfall 4: Composition Events and IME
If your application serves users who type with Input Method Editors (Chinese, Japanese, Korean), you need to test composition events. Slate handles compositionstart, compositionupdate, and compositionend events differently from regular text input. During composition, Slate defers normalization until compositionend fires. Playwright cannot easily simulate IME composition, but you can test the edges by dispatching synthetic composition events via page.evaluate() and verifying that the editor does not crash or produce corrupted state.
10. Writing These Scenarios in Plain English with Assrt
The Playwright code above is thorough, but it is also verbose. The void element test alone requires 40+ lines of TypeScript to insert an image, click it, verify the selection, delete it, and check the document state. Multiply that by six scenarios and you have a test file that is hundreds of lines long. Every Slate upgrade, every rendering layer change, every new normalization rule can break locators, data attributes, or DOM structure assumptions scattered across all of those lines.
Assrt lets you describe the same scenarios in plain English. Each scenario block compiles into Playwright TypeScript under the hood. When your Slate version upgrades or your renderElement output changes, Assrt detects the failures, analyzes the new DOM, and generates updated selectors automatically. Your scenario files stay stable.
Here is the void element scenario from Section 5, rewritten as an Assrt file:
That is 38 lines of readable English versus over 80 lines of TypeScript with manual DOM queries, evaluate calls, and assertion boilerplate. The Assrt scenarios read like a QA checklist that any team member can review, regardless of whether they know Playwright or Slate internals.
---
scenario: Nested list inside blockquote
steps:
- go to /editor
- click the editor area
- create a blockquote
- type "Opening thought"
- press Enter
- switch to bulleted list
- type "First point"
- press Enter
- type "Second point"
- press Enter
- type "Third point"
expect:
- a blockquote contains a bulleted list with 3 items
- "First point" is the first list item
- "Third point" is the last list item
then:
- press Enter
- type "Fourth point"
expect:
- the bulleted list now has 4 items
- the blockquote still wraps the entire list
---
scenario: Paste HTML preserves formatting
steps:
- go to /editor
- click the editor area
- paste HTML containing bold text, a link, and a list
expect:
- bold text appears in bold formatting
- the link points to the correct URL
- a bulleted list with 2 items is createdAssrt compiles each scenario block into the same Playwright TypeScript you saw in the preceding sections, committed to your repo as real tests you can read, run, and modify. When Slate changes its rendering output or your custom components update their data attributes, Assrt detects the failure, analyzes the new DOM, and opens a pull request with updated locators. Your scenario files stay untouched.
Start with the basic typing scenario. Once it is green in your CI, add the void element test, then the nested blocks, then the normalization stress tests, then the paste deserialization, and finally the cross-block selection scenario. In a single afternoon you can have comprehensive Slate editor coverage that catches regressions before they reach your users.
Related Guides
How to Test CodeMirror
A practical, scenario-by-scenario guide to testing CodeMirror 6 editors with Playwright....
How to Test Lexical Editor
A practical, scenario-by-scenario guide to testing Lexical rich text editors with...
How to Test Monaco Editor
A practical, scenario-by-scenario guide to testing Monaco Editor with Playwright. Covers...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.