Skip to main content

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

// Fast, deterministic tests
const editor = createHeadlessEditor();

// Test node transformations, commands, etc.
// without DOM overhead
// Test with real DOM when needed
const editor = createTestEditor();
const container = document.createElement('div');
editor.setRootElement(container);

// Test updateDOM, DOM events, etc.
vi.mock('@/api/save', () => ({
  saveContent: vi.fn().mockResolvedValue({ success: true }),
}));
afterEach(() => {
  // Clean up DOM
  document.body.innerHTML = '';
  
  // Clear mocks
  vi.clearAllMocks();
});

Coverage Goals

Node Coverage

  • createDOM creates correct element
  • updateDOM handles all property changes
  • Serialization round-trips correctly
  • isInline returns 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
    });
  });
});

Headless Mode

Fast testing without DOM

Performance

Benchmark and profile tests

Custom Nodes

Learn what to test in nodes

Transforms

Test transform behavior

Build docs developers (and LLMs) love