Code Editor Testing Guide
How to Test CodeMirror with Playwright: Complete 2026 Guide
A scenario-by-scenario walkthrough of testing CodeMirror 6 editors with Playwright. Contenteditable surfaces, hidden textareas, search and replace widgets, line gutters, code folding, extensions, compartments, and the pitfalls that silently break real editor test suites.
“CodeMirror is downloaded over one million times per week on npm and powers code editors in products like Chrome DevTools, Replit, Obsidian, and hundreds of developer tools.”
CodeMirror 6 Input Architecture
1. Why Testing CodeMirror Is Harder Than It Looks
CodeMirror 6 does not use a standard <textarea> or <input> element for text entry. Instead, it renders a contenteditablediv that the library manages entirely through its own transaction and state system. A hidden textarea sits behind the contenteditable surface to capture composition events, mobile keyboards, and IME input. This architecture means that Playwright's fill() method, which works by setting the value property on native form elements, does nothing useful on a CodeMirror editor. You must simulate real keyboard input instead.
The complexity goes further. CodeMirror virtualizes its line rendering: only lines visible in the viewport are present in the DOM. If your document is 500 lines long and only 30 lines fit on screen, the other 470 lines do not exist as DOM nodes. Scrolling down creates new line elements and destroys old ones. Any test that tries to locate a line by querying all .cm-line elements will miss content that is offscreen. You need to scroll programmatically and wait for the DOM to update before asserting.
CodeMirror also has its own widget layer for features like search and replace, autocomplete panels, tooltips, lint diagnostics, and code folding markers. These widgets live in dedicated DOM containers that overlay the editor. They are not part of the main contenteditable tree, so locating them requires knowing which CSS class or ARIA role CodeMirror assigns to each panel. The search panel, for example, lives in a .cm-searchcontainer with its own input fields and buttons that are separate from the editor's input mechanism. Line gutters are another independent DOM tree adjacent to the code lines, each gutter element mapped to a line index rather than a line element.
Finally, CodeMirror 6 is built around a functional state model with extensions and compartments. Extensions like syntax highlighting, bracket matching, or custom keymaps can be swapped at runtime by reconfiguring a compartment. Your tests need to verify that reconfiguration actually took effect, which means asserting on the resulting DOM changes rather than the configuration itself. There are five structural reasons this is hard: contenteditable instead of native inputs, virtualized line rendering, separate widget panel DOM, gutter trees decoupled from lines, and a functional state model that mutates the DOM indirectly through transactions.
CodeMirror 6 DOM Architecture
cm-editor
Root wrapper div
cm-scroller
Scrollable viewport
cm-content
contenteditable div
cm-line (n)
Virtualized lines
cm-gutters
Line numbers / markers
cm-panels
Search, diagnostics
cm-tooltip
Autocomplete, hover
2. Setting Up Your Test Environment
Before writing any CodeMirror tests, you need a reliable way to locate the editor instance and interact with it through Playwright. The setup differs based on whether you control the application code (and can expose the EditorView to the test harness) or are testing a third-party app where you only have DOM access.
Installing Dependencies
The Editor Locator Helper
Every CodeMirror 6 instance wraps itself in a div with the class .cm-editor. Inside that wrapper, the contenteditable surface has the class .cm-content and the role textbox. Use this combination as your primary entry point. If the page has multiple editors, scope your locator to a parent container with a test ID or a unique ancestor.
Exposing EditorView for Test Access
If you own the application code, the most reliable way to read and write editor content in tests is to expose the EditorView instance on the DOM element. CodeMirror 6 already stores an internal reference via cmView on the root element, but relying on internal properties is fragile. A better approach is to attach the view explicitly in your application code.
Test Environment Setup Flow
Install Playwright
npm init playwright@latest
Add CM Types
For test helpers
Create Helpers
cmEditor() locator
Expose View
__editorView on DOM
Write Fixtures
Reusable editor setup
3. Scenario: Typing Content Into the Editor
The most fundamental operation is entering text into a CodeMirror editor. Because the editor uses a contenteditable div rather than a native input, you cannot use Playwright's fill() method. Instead, you must click the editor to focus it and then use type() or keyboard.type() to simulate real keystrokes. The pressSequentially() method (renamed from the older type()) sends individual key events that CodeMirror's input handler processes through its transaction pipeline.
For bulk content insertion (pasting a 200-line file), individual keystrokes are too slow. Use page.evaluate() to dispatch a transaction directly on the EditorView. This bypasses the keystroke simulation entirely and inserts text at the current selection in a single operation, exactly as a paste event would.
Typing and Bulk Insertion
ModeratePlaywright Implementation
Editor Typing: Playwright vs Assrt
import { test, expect } from '@playwright/test';
import { cmEditor, getEditorContent } from './helpers/codemirror';
test('type into CodeMirror editor', async ({ page }) => {
await page.goto('/editor');
const cm = cmEditor(page);
await cm.content.click();
await cm.content.pressSequentially('function hello() {');
await page.keyboard.press('Enter');
await cm.content.pressSequentially(' console.log("world");');
await page.keyboard.press('Enter');
await cm.content.pressSequentially('}');
const content = await getEditorContent(page);
expect(content).toContain('function hello()');
expect(content).toContain('console.log("world")');
});4. Scenario: Reading and Asserting Editor Content
Reading content from a CodeMirror editor is trickier than it sounds. The visible .cm-line elements only represent lines currently in the viewport. If you scrape textContent from the contenteditable div, you get only the rendered lines, and whitespace formatting may differ from the actual document. The reliable approach is to read the document from the EditorState directly using page.evaluate().
You also need to assert specific editor states beyond just content: cursor position, selection ranges, active line highlighting, and syntax highlighting tokens. Each of these requires a different query strategy. Cursor position is stored in state.selection.main.head, selections are ranges, and syntax tokens are accessible through the language tree associated with the state.
Reading Content and Cursor State
ModeratePlaywright Implementation
Reading Content: Playwright vs Assrt
test('read editor content via EditorState', async ({ page }) => {
await page.goto('/editor?file=example.ts');
const doc = await page.evaluate(() => {
const el = document.querySelector('.cm-editor') as any;
return el?.cmView?.view?.state.doc.toString() ?? '';
});
expect(doc).toContain('export function');
expect(doc.split('\n').length).toBeGreaterThan(5);
});5. Scenario: Search and Replace Widget
CodeMirror 6's built-in search extension renders a panel at the top of the editor with its own input fields, buttons, and keyboard shortcuts. The panel lives inside a .cm-searchcontainer and includes a search input, a replace input (when in replace mode), navigation buttons, and toggles for case sensitivity and regex mode. This panel is entirely separate from the editor's contenteditable surface, so you interact with it using standard Playwright locators on the panel's form elements.
Opening the search panel requires the correct keyboard shortcut. On most configurations, Ctrl+F (or Cmd+F on macOS) opens the search panel, and Ctrl+H (or Cmd+Alt+F on macOS) opens search with replace. The tricky part is that these shortcuts only work when the editor is focused, and the search panel captures focus once opened. Navigating between matches uses Enter (next) and Shift+Enter (previous) while the search input is focused.
Search and Replace Widget
ComplexPlaywright Implementation
6. Scenario: Line Gutters and Breakpoints
CodeMirror renders line gutters in a separate DOM tree next to the code content. The gutter container has the class .cm-gutters and contains child elements for each gutter type: line numbers ( .cm-lineNumbers), fold indicators ( .cm-foldGutter), and any custom gutters your application defines (breakpoints, error markers, lint icons). Each gutter element is vertically aligned with its corresponding line in the editor, but they are not nested inside the same parent, so you cannot use CSS sibling selectors to connect a gutter marker to its line content.
Testing gutter interactions like breakpoint toggling requires clicking the gutter element at a specific line. The challenge is that gutter elements are positioned absolutely and matched to lines by vertical offset, not by a data attribute linking to a line number. The most reliable approach is to locate gutter elements within the gutter container and click the element at the correct index, or use page.evaluate() to trigger gutter effects programmatically through the EditorView.
Line Gutters and Breakpoints
ComplexPlaywright Implementation
Gutter Breakpoints: Playwright vs Assrt
test('toggle breakpoint on gutter click', async ({ page }) => {
await page.goto('/editor?file=example.ts&breakpoints=true');
const cm = cmEditor(page);
const bpGutter = cm.root.locator('.cm-breakpoint-gutter');
await expect(bpGutter).toBeVisible();
const gutterElements = bpGutter.locator('.cm-gutterElement');
await gutterElements.nth(3).click();
const marker = gutterElements.nth(3).locator('.breakpoint-marker');
await expect(marker).toBeVisible();
await gutterElements.nth(3).click();
await expect(marker).not.toBeVisible();
});7. Scenario: Code Folding and Expanding
Code folding in CodeMirror 6 is provided by the @codemirror/language package through the foldGutter extension. When enabled, fold markers appear in a dedicated gutter column. Clicking a fold marker on a line that starts a foldable range (like a function body, object literal, or block statement) collapses the range into a placeholder widget, removing those lines from the visible DOM. The fold gutter markers use the class .cm-foldGutterwith child elements that toggle between a “fold” indicator and an “unfold” indicator.
Testing code folding has a specific gotcha: folded lines are replaced by a .cm-foldPlaceholder widget in the DOM. The original lines are removed from the rendered output entirely (they still exist in the document state, just not in the DOM). So after folding, a line count assertion on .cm-lineelements will show fewer lines than the document actually contains. You must distinguish between “rendered lines” (DOM) and “document lines” (state) in your assertions.
Code Folding and Expanding
ModeratePlaywright Implementation
8. Scenario: Dynamic Extensions and Compartments
CodeMirror 6 uses a compartment system for runtime extension management. A Compartment wraps one or more extensions and allows you to replace, add, or remove them without recreating the entire editor. Common use cases include toggling between light and dark themes, switching language modes when the user changes file types, enabling or disabling vim keybindings, and adjusting tab size or indent settings on the fly.
Testing compartment reconfiguration means verifying the side effects of the reconfiguration. If you switch from JavaScript syntax highlighting to Python, the DOM should show Python-specific tokens. If you toggle a read-only extension, the editor should reject input. If you change the theme, CSS classes on the editor root should change. You cannot directly observe the compartment state from the DOM; you observe its effects.
Dynamic Extensions and Compartments
ComplexPlaywright Implementation
Extension Toggle: Playwright vs Assrt
test('toggle read-only mode via compartment', async ({ page }) => {
await page.goto('/editor?readOnlyToggle=true');
const cm = cmEditor(page);
await expect(cm.content).toHaveAttribute('contenteditable', 'true');
await page.getByRole('button', { name: /read.only/i }).click();
await expect(cm.content).toHaveAttribute('contenteditable', 'false');
await cm.content.click({ force: true });
await page.keyboard.type('should not appear');
const content = await page.evaluate(() => {
const el = document.querySelector('.cm-editor') as any;
return el?.cmView?.view?.state.doc.toString() ?? '';
});
expect(content).not.toContain('should not appear');
});9. Common Pitfalls That Break CodeMirror Test Suites
After testing dozens of CodeMirror integrations, these are the recurring failures that break test suites in CI. Each pitfall is sourced from real issues reported in the CodeMirror GitHub repository, Stack Overflow threads, and Playwright issue trackers.
Pitfall 1: Using fill() on contenteditable
Playwright's fill() method sets the value property on input and textarea elements. It does not work on contenteditable divs because they have no value property. Calling fill() on a CodeMirror editor will either throw an error or silently do nothing. Use pressSequentially() for keystroke simulation or page.evaluate() for bulk insertion via the EditorView API.
Pitfall 2: Asserting on .cm-line count for total lines
CodeMirror virtualizes line rendering. Only lines within the visible viewport are present as DOM nodes. If your document has 200 lines but only 30 are visible, querying .cm-line returns approximately 30 elements. Tests that assert on line count must either scroll to render all lines (slow and unreliable) or read the line count from state.doc.lines via page.evaluate().
Pitfall 3: Race conditions with syntax highlighting
Syntax highlighting in CodeMirror 6 is asynchronous. The language parser runs incrementally and may not finish tokenizing the document by the time your assertion runs. If you insert code and immediately check for .tok-keyword elements, they may not exist yet. Add a small wait or poll for the expected token class to appear. The toHaveText()matcher with Playwright's built-in auto-retry is usually sufficient.
Pitfall 4: Keyboard shortcuts not firing when editor is unfocused
CodeMirror keymaps only respond when the editor has focus. If your test opens a dialog, clicks outside the editor, or switches browser tabs, the next keyboard.press('Ctrl+F') call targets the page body, not the editor. The search panel will not open, and your test fails with a timeout waiting for .cm-search. Always click the editor content to restore focus before sending keyboard shortcuts.
Pitfall 5: Stale EditorView references after hot reload
In development servers with hot module replacement, CodeMirror instances can be destroyed and recreated without a full page reload. If your test stores a reference to the EditorView (via page.evaluate() that captures a closure), that reference points to a stale, disconnected view. Always re-query the EditorView from the DOM element inside each page.evaluate() call rather than caching it.
CodeMirror Testing Anti-Patterns
- Using fill() on the contenteditable surface
- Counting .cm-line elements for total document lines
- Asserting syntax tokens immediately after insertion
- Sending keyboard shortcuts without focusing the editor first
- Caching EditorView references across hot reloads
- Scrolling to a line by pixel offset instead of using scrollIntoView
- Testing folded content by looking for DOM nodes that no longer exist
- Assuming gutter elements share a parent with their corresponding lines
CodeMirror Testing Best Practices
- Use pressSequentially() for keystroke-based typing
- Read content from EditorState, not DOM textContent
- Focus the editor before sending keyboard shortcuts
- Wait for syntax highlighting before asserting tokens
- Use page.evaluate() for bulk content insertion
- Expose EditorView on the DOM element in test builds
- Distinguish rendered lines (DOM) from document lines (state)
- Re-query EditorView inside each evaluate call
10. Writing These Scenarios in Plain English with Assrt
Every Playwright scenario in this guide required knowledge of CodeMirror's internal DOM structure: the .cm-editor wrapper, the .cm-content contenteditable, the .cm-search panel, the .cm-foldGutter markers, and the .cm-foldPlaceholder widgets. With Assrt, you describe what you want to test in plain English, and the compiler resolves the correct selectors, handles focus management, and generates the Playwright TypeScript for you.
Here is the search and replace scenario from Section 5, compiled into an Assrt scenario file. Notice how the intent is clear without any CSS selectors, page.evaluate calls, or timing hacks.
Assrt 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 CodeMirror upgrades and changes a CSS class from .cm-foldPlaceholder to something else, or when the search panel restructures its input layout, Assrt detects the failure, analyzes the new DOM, and opens a pull request with the updated locators. Your scenario files stay untouched.
Start with the typing scenario. Once it is green in your CI, add the search and replace test, then code folding, then gutter interactions, then dynamic extension toggling. In a single afternoon you can have complete CodeMirror editor coverage that most teams never manage to achieve by hand. The contenteditable complexity, virtualized rendering, and widget panel architecture are all abstracted behind plain English descriptions.
Related Guides
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...
How to Test Notion Onboarding
A practical guide to testing Notion workspace onboarding with Playwright. Covers template...
Ready to automate your testing?
Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.