Overview
Testing contenteditable editors is challenging due to browser differences, IME behavior, and DOM complexity. This guide covers testing strategies at different levels.
Testing Challenges
Key challenges in testing contenteditable editors:
- Browser differences in event firing and DOM behavior
- IME composition events don't fire consistently across browsers
- Selection API differences between browsers
- Mobile browsers have different behavior than desktop
- Timing issues with async operations and DOM updates
Unit Testing
Test model operations and transformations in isolation.
Model Testing
import { describe, it, expect } from 'vitest';
import { Editor } from './editor';
describe('Model Operations', () => {
it('should insert text at cursor', () => {
const editor = new Editor();
editor.setContent('Hello world');
editor.setSelection({ start: 5, end: 5 });
editor.insertText(' beautiful');
expect(editor.getContent()).toBe('Hello beautiful world');
});
it('should delete selected text', () => {
const editor = new Editor();
editor.setContent('Hello world');
editor.setSelection({ start: 0, end: 5 });
editor.deleteContent();
expect(editor.getContent()).toBe(' world');
});
});Operation Testing
describe('Operations', () => {
it('should apply insert operation', () => {
const doc = { type: 'doc', children: [] };
const operation = {
type: 'insert',
path: [0],
node: { type: 'text', text: 'Hello' }
};
const result = applyOperation(doc, operation);
expect(result.children[0].text).toBe('Hello');
});
it('should support undo/redo', () => {
const editor = new Editor();
editor.insertText('Hello');
editor.insertText(' world');
editor.undo();
expect(editor.getContent()).toBe('Hello');
editor.redo();
expect(editor.getContent()).toBe('Hello world');
});
});Integration Testing
Test DOM synchronization and event handling.
DOM Testing
import { render, screen, fireEvent } from '@testing-library/react';
describe('DOM Synchronization', () => {
it('should update DOM when model changes', () => {
const { container } = render(<Editor />);
const editor = container.querySelector('[contenteditable]');
// Simulate input
fireEvent.input(editor, {
target: { textContent: 'Hello world' }
});
expect(editor.textContent).toBe('Hello world');
});
});Event Testing
describe('Event Handling', () => {
it('should handle beforeinput events', () => {
const editor = new Editor();
const element = editor.element;
const handler = vi.fn();
editor.on('beforeinput', handler);
const event = new InputEvent('beforeinput', {
inputType: 'insertText',
data: 'Hello'
});
element.dispatchEvent(event);
expect(handler).toHaveBeenCalled();
});
});IME Testing
IME testing requires special handling due to browser differences.
Composition Testing
describe('IME Composition', () => {
it('should handle composition events', async () => {
const editor = new Editor();
const element = editor.element;
// Simulate composition
element.dispatchEvent(new CompositionEvent('compositionstart'));
element.dispatchEvent(new CompositionEvent('compositionupdate', { data: '한' }));
element.dispatchEvent(new CompositionEvent('compositionupdate', { data: '한글' }));
element.dispatchEvent(new CompositionEvent('compositionend', { data: '한글' }));
await new Promise(resolve => setTimeout(resolve, 100));
expect(editor.getContent()).toContain('한글');
});
});Cross-Browser IME Testing
Note: iOS Safari doesn't fire composition events for Korean IME. Test with real devices or browser automation tools.
End-to-End Testing
Test full user workflows with browser automation.
Playwright Testing
import { test, expect } from '@playwright/test';
test('should insert text', async ({ page }) => {
await page.goto('/editor');
const editor = page.locator('[contenteditable]');
await editor.click();
await editor.type('Hello world');
await expect(editor).toHaveText('Hello world');
});
test('should handle IME composition', async ({ page }) => {
await page.goto('/editor');
const editor = page.locator('[contenteditable]');
await editor.click();
// Type Korean characters
await editor.pressSequentially('한글');
await expect(editor).toContainText('한글');
});Mobile Testing
Mobile testing requires real devices or emulation.
test('should handle virtual keyboard', async ({ page, isMobile }) => {
if (!isMobile) test.skip();
await page.goto('/editor');
const editor = page.locator('[contenteditable]');
await editor.tap();
// Virtual keyboard should appear
await editor.type('Hello');
await expect(editor).toHaveText('Hello');
});Performance Testing
Test editor performance with large documents.
test('should handle large documents', async () => {
const editor = new Editor();
const largeText = 'Hello '.repeat(10000);
const start = performance.now();
editor.setContent(largeText);
const end = performance.now();
expect(end - start).toBeLessThan(100); // Should complete in < 100ms
});Best Practices
- Test model operations in isolation
- Test DOM synchronization separately
- Use real IMEs for composition testing
- Test across multiple browsers
- Test on real mobile devices
- Mock external dependencies
- Test edge cases and error conditions