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:
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
KeyboardEventorInputEventon 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 testing | Tier |
|---|---|
| Node creation, transforms, serialization | Unit (jsdom) |
| Command dispatch and handler logic | Unit (jsdom) |
| Extension registration and config | Unit (jsdom) |
| Real keystroke input and typing | Browser |
| IME composition (Korean, Japanese, Chinese) | Browser |
| Selection from user interaction | Browser |
Clipboard paste with real DataTransfer | Browser |
| Full-application user scenarios | E2E |
| Cross-browser rendering differences | E2E |
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.