Overview
Testing Lexical editors requires understanding both unit testing (for individual nodes and transforms) and integration testing (for full editor behavior). This guide covers strategies for both, with practical examples.Lexical is tested with Vitest for unit tests and Playwright for E2E tests. You can use any testing framework, but these examples use the same tools.
Test Environment Setup
Unit Testing with Vitest
pnpm add -D vitest @lexical/headless happy-dom
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // or 'happy-dom' for faster tests
globals: true,
},
});
Basic Test Setup
test-utils.ts
import { createEditor, LexicalEditor } from 'lexical';
import { createHeadlessEditor } from '@lexical/headless';
export function createTestEditor(config = {}) {
return createEditor({
namespace: 'test',
onError: (error) => {
throw error;
},
...config,
});
}
export function createTestHeadlessEditor(config = {}) {
return createHeadlessEditor({
namespace: 'test',
onError: (error) => {
throw error;
},
...config,
});
}
Testing Custom Nodes
Node Creation and Serialization
import { describe, it, expect, beforeEach } from 'vitest';
import { $getRoot, $createParagraphNode } from 'lexical';
import { createTestEditor } from './test-utils';
describe('CustomNode', () => {
let editor: LexicalEditor;
beforeEach(() => {
editor = createTestEditor({
nodes: [CustomNode],
});
});
it('should create node with correct properties', () => {
editor.update(() => {
const node = $createCustomNode('test-value');
expect(node.getType()).toBe('custom');
expect(node.__value).toBe('test-value');
expect(node.isAttached()).toBe(false);
});
});
it('should serialize and deserialize correctly', () => {
let originalKey: string;
editor.update(() => {
const node = $createCustomNode('test-value');
$getRoot().append($createParagraphNode().append(node));
originalKey = node.getKey();
});
// Serialize
const state = editor.getEditorState();
const json = JSON.stringify(state.toJSON());
// Create new editor and deserialize
const newEditor = createTestEditor({
nodes: [CustomNode],
});
newEditor.setEditorState(
newEditor.parseEditorState(json)
);
// Verify
newEditor.read(() => {
const root = $getRoot();
const paragraph = root.getFirstChild();
const node = paragraph?.getFirstChild();
expect($isCustomNode(node)).toBe(true);
expect(node?.__value).toBe('test-value');
});
});
});
Testing updateDOM
import { describe, it, expect } from 'vitest';
describe('CustomNode updateDOM', () => {
it('should update DOM properties', () => {
const editor = createTestEditor({
nodes: [CustomNode],
});
const container = document.createElement('div');
editor.setRootElement(container);
editor.update(() => {
const node = $createCustomNode('initial');
$getRoot().append($createParagraphNode().append(node));
});
// Get the DOM element
const element = container.querySelector('[data-custom-node]');
expect(element?.textContent).toBe('initial');
// Update the node
editor.update(() => {
const node = $getRoot()
.getFirstChild()!
.getFirstChild() as CustomNode;
node.getWritable().setValue('updated');
});
// Verify DOM updated
expect(element?.textContent).toBe('updated');
});
it('should return true when tag needs to change', () => {
const prevNode = $createCustomNode('test');
prevNode.__tag = 'div';
const nextNode = $createCustomNode('test');
nextNode.__tag = 'span';
const dom = document.createElement('div');
const config = {} as EditorConfig;
const shouldReplace = nextNode.updateDOM(prevNode, dom, config);
expect(shouldReplace).toBe(true);
});
});
Testing Transforms
Transform Execution
import { describe, it, expect, vi } from 'vitest';
import { TextNode } from 'lexical';
describe('AutoLink Transform', () => {
it('should convert URLs to links', () => {
const editor = createTestEditor({
nodes: [LinkNode],
});
// Register transform
const transform = vi.fn((node: TextNode) => {
const text = node.getTextContent();
if (isURL(text)) {
const link = $createLinkNode(text);
node.replace(link);
link.append(node);
}
});
editor.registerNodeTransform(TextNode, transform);
editor.update(() => {
const text = $createTextNode('https://example.com');
$getRoot().append($createParagraphNode().append(text));
});
// Transform should have been called
expect(transform).toHaveBeenCalled();
// Verify result
editor.read(() => {
const paragraph = $getRoot().getFirstChild()!;
const link = paragraph.getFirstChild();
expect($isLinkNode(link)).toBe(true);
expect(link?.getURL()).toBe('https://example.com');
});
});
it('should not create infinite loops', () => {
const editor = createTestEditor();
let transformCount = 0;
editor.registerNodeTransform(TextNode, (node) => {
transformCount++;
// This would cause infinite loop if not handled
if (transformCount < 5) {
node.getWritable().setTextContent(
node.getTextContent() + '!'
);
}
});
editor.update(() => {
$getRoot().append(
$createParagraphNode().append(
$createTextNode('test')
)
);
});
// Should stabilize after a few iterations
expect(transformCount).toBeLessThan(100);
});
});
Transform Order
describe('Transform execution order', () => {
it('should run transforms in correct order', () => {
const editor = createTestEditor();
const calls: string[] = [];
editor.registerNodeTransform(TextNode, () => {
calls.push('text-transform');
});
editor.registerNodeTransform(ParagraphNode, () => {
calls.push('paragraph-transform');
});
editor.update(() => {
$getRoot().append(
$createParagraphNode().append(
$createTextNode('test')
)
);
});
// Leaves (TextNode) transform before elements (ParagraphNode)
expect(calls).toEqual(['text-transform', 'paragraph-transform']);
});
});
Testing Commands
Command Handlers
import { createCommand, COMMAND_PRIORITY_NORMAL } from 'lexical';
const CUSTOM_COMMAND = createCommand<string>('CUSTOM_COMMAND');
describe('Command handling', () => {
it('should execute command handler', () => {
const editor = createTestEditor();
const handler = vi.fn((payload: string) => {
expect(payload).toBe('test-payload');
return true;
});
editor.registerCommand(
CUSTOM_COMMAND,
handler,
COMMAND_PRIORITY_NORMAL
);
editor.dispatchCommand(CUSTOM_COMMAND, 'test-payload');
expect(handler).toHaveBeenCalledWith('test-payload', editor);
});
it('should respect command priority', () => {
const editor = createTestEditor();
const calls: string[] = [];
editor.registerCommand(
CUSTOM_COMMAND,
() => {
calls.push('low');
return false;
},
COMMAND_PRIORITY_LOW
);
editor.registerCommand(
CUSTOM_COMMAND,
() => {
calls.push('high');
return true; // Stop propagation
},
COMMAND_PRIORITY_HIGH
);
editor.dispatchCommand(CUSTOM_COMMAND, 'test');
// High priority runs first and stops propagation
expect(calls).toEqual(['high']);
});
});
Testing with Headless Editor
Fast Unit Tests
import { createHeadlessEditor } from '@lexical/headless';
describe('Content processing (headless)', () => {
it('should extract text content', () => {
const editor = createHeadlessEditor();
editor.update(() => {
$getRoot().append(
$createParagraphNode().append(
$createTextNode('Hello'),
$createTextNode(' '),
$createTextNode('World')
)
);
});
const text = editor.getEditorState().read(() =>
$getRoot().getTextContent()
);
expect(text).toBe('Hello World');
});
it('should process large content efficiently', () => {
const editor = createHeadlessEditor();
const start = performance.now();
editor.update(() => {
const root = $getRoot();
for (let i = 0; i < 1000; i++) {
root.append(
$createParagraphNode().append(
$createTextNode(`Paragraph ${i}`)
)
);
}
});
const end = performance.now();
// Should be very fast without DOM
expect(end - start).toBeLessThan(100);
});
});
Testing React Plugins
Plugin Component Testing
import { render, waitFor } from '@testing-library/react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
describe('MyPlugin', () => {
it('should register listeners', async () => {
const onUpdate = vi.fn();
function TestEditor() {
const initialConfig = {
namespace: 'test',
onError: (error: Error) => throw error,
};
return (
<LexicalComposer initialConfig={initialConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<MyPlugin onUpdate={onUpdate} />
</LexicalComposer>
);
}
render(<TestEditor />);
await waitFor(() => {
expect(onUpdate).toHaveBeenCalled();
});
});
});
Snapshot Testing
Editor State Snapshots
describe('Editor state snapshots', () => {
it('should match snapshot', () => {
const editor = createTestEditor({
nodes: [CustomNode],
});
editor.update(() => {
const root = $getRoot();
root.append(
$createParagraphNode().append(
$createTextNode('Hello'),
$createCustomNode('world')
)
);
});
const state = editor.getEditorState().toJSON();
expect(state).toMatchSnapshot();
});
});
E2E Testing with Playwright
Setup
pnpm add -D @playwright/test
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'pnpm run dev',
port: 3000,
},
});
E2E Test Example
e2e/editor.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Editor', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should type text', async ({ page }) => {
const editor = page.locator('[contenteditable="true"]');
await editor.click();
await editor.type('Hello World');
await expect(editor).toHaveText('Hello World');
});
test('should apply formatting', async ({ page }) => {
const editor = page.locator('[contenteditable="true"]');
await editor.click();
await editor.type('Bold text');
// Select all
await page.keyboard.press('Meta+A');
// Make bold
await page.keyboard.press('Meta+B');
const bold = page.locator('strong');
await expect(bold).toHaveText('Bold text');
});
});
Test Utilities
Common Test Helpers
test-helpers.ts
import { $getRoot, $getSelection, $isRangeSelection } from 'lexical';
export function $assertRangeSelection(selection: unknown) {
if (!$isRangeSelection(selection)) {
throw new Error('Expected RangeSelection');
}
return selection;
}
export function getEditorStateTextContent(editor: LexicalEditor): string {
return editor.getEditorState().read(() => $getRoot().getTextContent());
}
export async function waitForUpdate(editor: LexicalEditor): Promise<void> {
return new Promise((resolve) => {
const remove = editor.registerUpdateListener(() => {
remove();
resolve();
});
});
}
export function initializeEditor(
callback: (editor: LexicalEditor) => void
) {
const editor = createTestEditor();
const container = document.createElement('div');
editor.setRootElement(container);
callback(editor);
return { editor, container };
}
Best Practices
Use Headless for Pure Logic
Use Headless for Pure Logic
// Fast, deterministic tests
const editor = createHeadlessEditor();
// Test node transformations, commands, etc.
// without DOM overhead
Test DOM Integration Separately
Test DOM Integration Separately
// Test with real DOM when needed
const editor = createTestEditor();
const container = document.createElement('div');
editor.setRootElement(container);
// Test updateDOM, DOM events, etc.
Mock External Dependencies
Mock External Dependencies
vi.mock('@/api/save', () => ({
saveContent: vi.fn().mockResolvedValue({ success: true }),
}));
Clean Up After Tests
Clean Up After Tests
afterEach(() => {
// Clean up DOM
document.body.innerHTML = '';
// Clear mocks
vi.clearAllMocks();
});
Coverage Goals
Node Coverage
createDOMcreates correct elementupdateDOMhandles all property changes- Serialization round-trips correctly
isInlinereturns correct value
Transform Coverage
- Handles all expected inputs
- Doesn’t create infinite loops
- Properly checks node state
- Works with composition
Command Coverage
- All priorities tested
- Payload types validated
- Propagation behavior correct
- Edge cases handled
Integration Coverage
- User interactions work
- Plugins interact correctly
- Performance is acceptable
- No memory leaks
Debugging Tests
import { enableLexicalDebug } from 'lexical';
describe('Debug tests', () => {
it('should log detailed info', () => {
const editor = createTestEditor();
// Enable detailed logging
if (__DEV__) {
enableLexicalDebug(editor);
}
editor.update(() => {
// Your test logic
// Will log detailed reconciliation info
});
});
});
Related Resources
Headless Mode
Fast testing without DOM
Performance
Benchmark and profile tests
Custom Nodes
Learn what to test in nodes
Transforms
Test transform behavior