Skip to main content

Testing

Lexical has three tiers of tests: unit tests in jsdom, browser tests in a real browser, and end-to-end tests against the playground. The main thing to know is that contentEditable doesn't work in jsdom, so anything involving real user input needs a browser.

Unit tests (jsdom)

Unit tests run in vitest with a jsdom environment. They are fast (the full suite finishes in ~20 seconds) and cover the majority of Lexical's API surface.

pnpm test-unit

Test files live under packages/**/src/__tests__/unit/ and packages/**/__tests__/unit/.

Why user input doesn't work in jsdom

If you've tried userEvent.type() or fireEvent.input() from React Testing Library and nothing happened, here's why:

info

jsdom does not implement contentEditable editing. In a real browser, a keystroke inside a contentEditable element goes through the browser's native editing engine, which modifies the DOM and fires beforeinput/input events that Lexical observes. jsdom has no such engine, so dispatching synthetic InputEvents does nothing — there is nothing to actually insert the text into the DOM.

The same applies to ProseMirror, Slate, Tiptap, and any other contentEditable-based editor. The workaround is the same everywhere: use Lexical's API to manipulate state in unit tests, and use a real browser when you need real input.

What you can test in jsdom

Anything that goes through Lexical's API rather than the browser's input pipeline:

import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
CONTROLLED_TEXT_INSERTION_COMMAND,
} from 'lexical';

// Create an editor with a root element attached
const editor = buildEditorFromExtensions({
$initialEditorState: () => {
$getRoot().append(
$createParagraphNode().append($createTextNode('hello')),
);
},
dependencies: [RichTextExtension],
name: 'test',
});

const root = document.createElement('div');
root.contentEditable = 'true';
document.body.appendChild(root);
editor.setRootElement(root);

Insert text via the selection API:

editor.update(() => {
$getRoot().selectEnd();
}, {discrete: true});

editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(' world');
}
}, {discrete: true});

editor.read(() => {
expect($getRoot().getTextContent()).toBe('hello world');
});

{discrete: true} makes the update commit synchronously instead of being batched, so each step's effects are visible to the next one.

Or insert text via a command. This also requires a selection to be set first:

editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, ' world');

Both approaches update the editor state and the DOM. Use whichever is closer to the code path you're testing.

What you cannot test in jsdom

  • Typing by dispatching KeyboardEvent or InputEvent on the DOM
  • IME / composition sequences
  • Selection changes driven by mouse clicks or arrow keys
  • Clipboard paste with real browser DataTransfer
  • Any behavior that depends on the browser's native editing engine

If your test needs any of these, use browser tests.

Editor cleanup with using

buildEditorFromExtensions returns an editor with a dispose() method and Symbol.dispose support. Declaring it with using automatically cleans up registrations at the end of the block:

it('inserts text', () => {
using editor = buildEditorFromExtensions({
dependencies: [RichTextExtension],
name: 'test',
});
// editor.dispose() is called automatically at the end of the block
});

Most of Lexical's own tests use this pattern. If you need manual cleanup (for example, in a shared mountEditor() helper), call editor.dispose() yourself or use vitest's onTestFinished callback.

Running specific tests

Pass a filename filter to run a subset of tests:

pnpm test-unit LexicalReconciler
pnpm test-browser compose

vitest matches the filter against file paths, so any substring of the filename works.

Browser tests (vitest browser mode)

Browser tests run in a real browser via vitest's browser mode, backed by Playwright. The browser has a real contentEditable engine, so input events, composition, and selection work as expected.

pnpm test-browser # default: chromium
VITEST_BROWSER=firefox pnpm test-browser # firefox
VITEST_BROWSER=chromium,firefox pnpm test-browser # multiple browsers

Test files live under packages/**/src/__tests__/browser/. They look like normal vitest tests. Here's an example that uses the compose() and korean() helpers to simulate an IME sequence:

import {buildEditorFromExtensions} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {$getRoot, $createParagraphNode} from 'lexical';
import {expect, test, onTestFinished} from 'vitest';
import {compose, korean} from './utils/compose';

test('IME composition inserts Korean text', async () => {
const editor = buildEditorFromExtensions({
$initialEditorState: () => {
$getRoot().append($createParagraphNode());
},
dependencies: [RichTextExtension],
name: 'test',
});
const rootElement = document.createElement('div');
rootElement.contentEditable = 'true';
document.body.appendChild(rootElement);
editor.setRootElement(rootElement);
onTestFinished(() => {
rootElement.remove();
editor.dispose();
});

rootElement.focus();
await compose({editor, rootElement}, korean(['ㅎ', '하', '한']));

editor.read(() => {
expect($getRoot().getTextContent()).toBe('한');
});
});

When to use which tier

What you're testingTier
Node creation, transforms, serializationUnit (jsdom)
Command dispatch and handler logicUnit (jsdom)
Extension registration and configUnit (jsdom)
Real keystroke input and typingBrowser
IME composition (Korean, Japanese, Chinese)Browser
Selection from user interactionBrowser
Clipboard paste with real DataTransferBrowser
Full-application user scenariosE2E
Cross-browser rendering differencesE2E

End-to-end (E2E) tests

E2E tests use Playwright to drive the full playground application in Chromium, Firefox, and WebKit. They click, type, and verify what appears on screen, with no access to Lexical internals. Install the browsers first:

npx playwright install

Then start the playground and run the tests:

pnpm start &
pnpm test-e2e-chromium # or -firefox, -webkit

Tests live under packages/lexical-playground/__tests__/ in e2e/ (proactive) and regression/ (reactive — one test per reported bug) directories.

General guidelines

  • New features must include tests.
  • If it can break, it should have a test.
  • Do not merge pull requests with failing tests — this blocks other people and releases.
  • Start with unit tests. Reach for browser tests when you need real input behavior. Use E2E for full-application scenarios.
  • If you're dispatching synthetic InputEvents in a unit test and they're not working, that's the signal to move to a browser test.