Testing behaviors
The Portable Text Editor ships with testing infrastructure you can use for your own behaviors. There are two approaches: Gherkin specs with Racejar (the same approach every official plugin uses) and direct Vitest tests for simpler cases.
Both approaches use Vitest Browser Mode with Playwright, running tests against a real browser.
Install the testing dependencies:
Configure Vitest for Browser Mode in your vitest.config.ts:
import {defineConfig} from 'vitest/config'
export default defineConfig({ test: { browser: { enabled: true, provider: 'playwright', instances: [{browser: 'chromium'}], }, },})If you use TypeScript, declare .feature file imports:
declare module '*.feature?raw' { const content: string export default content}Quick start: direct Vitest tests
Section titled “Quick start: direct Vitest tests”For simple behavior tests, use createTestEditor directly. This is the fastest way to verify a behavior works:
import {defineSchema} from '@portabletext/editor'import {defineBehavior, execute} from '@portabletext/editor/behaviors'import {BehaviorPlugin} from '@portabletext/editor/plugins'import {createTestEditor} from '@portabletext/editor/test/vitest'import {getTersePt} from '@portabletext/test'import {describe, expect, test, vi} from 'vitest'import {userEvent} from 'vitest/browser'
describe('uppercase A behavior', () => { test('replaces lowercase a with uppercase A', async () => { const {editor, locator} = await createTestEditor({ children: ( <BehaviorPlugin behaviors={[ defineBehavior({ on: 'insert.text', guard: ({event}) => event.text === 'a', actions: [() => [execute({type: 'insert.text', text: 'A'})]], }), ]} /> ), schemaDefinition: defineSchema({ decorators: [{name: 'strong'}], }), })
await userEvent.click(locator) await userEvent.type(locator, 'a')
await vi.waitFor(() => { expect(getTersePt(editor.getSnapshot().context)).toEqual(['A']) }) })})Key parts:
createTestEditorspins up a real editor in the browser. Returns{editor, locator}whereeditoris the editor instance andlocatoris a Playwright locator for user interactions.getTersePtfrom@portabletext/testgives you a compact representation of the editor content for assertions.['foo ,bar, baz']means one block with three spans (commas separate spans).editor.getSnapshot().contextgives you the full editor state includingvalue(the Portable Text array) andselection.vi.waitForis needed because editor updates are asynchronous.
Gherkin approach with Racejar
Section titled “Gherkin approach with Racejar”For comprehensive behavior specs, write Gherkin feature files and run them with Racejar. This is how every official plugin is tested.
Step 1: write a feature file
Section titled “Step 1: write a feature file”Create a .feature file that describes your behavior in plain English:
Feature: Auto-capitalize after period
Scenario: Capitalizes first letter after period and space Given the text "hello." When the editor is focused And the caret is put after "hello." And " " is typed And "w" is typed Then the text is "hello. W"
Scenario: Does not capitalize mid-sentence Given the text "hello" When the editor is focused And the caret is put after "hello" And " w" is typed Then the text is "hello w"
Scenario: Undo restores original text Given the text "hello." When the editor is focused And the caret is put after "hello." And " " is typed And "w" is typed Then the text is "hello. W" When undo is performed Then the text is "hello. w"Gherkin scenarios follow the Given/When/Then pattern:
- Given sets up the editor state (text content, marks, selections)
- When performs actions (typing, key presses, toolbar interactions)
- Then asserts the result (text content, marks, selections)
Step 2: wire up the test
Section titled “Step 2: wire up the test”Create a test file that connects your feature file to the editor:
import {defineSchema} from '@portabletext/editor'import {parameterTypes} from '@portabletext/editor/test'import { createTestEditor, stepDefinitions, type Context,} from '@portabletext/editor/test/vitest'import {Before} from 'racejar'import {Feature} from 'racejar/vitest'import {MyBehaviorPlugin} from './my-behavior-plugin'import myFeature from './my-behavior.feature?raw'
Feature({ hooks: [ Before(async (context: Context) => { const {editor, locator} = await createTestEditor({ children: <MyBehaviorPlugin />, schemaDefinition: defineSchema({ decorators: [{name: 'strong'}, {name: 'em'}], annotations: [{name: 'link'}], }), })
context.locator = locator context.editor = editor }), ], featureText: myFeature, stepDefinitions, parameterTypes,})That’s it. Racejar compiles the .feature file into test cases, and the pre-built step definitions handle the Given/When/Then steps.
Step 3: run the tests
Section titled “Step 3: run the tests”npx vitestEach Gherkin scenario becomes a separate test case. Failures show which step failed and what the editor state was at that point.
Pre-built step definitions
Section titled “Pre-built step definitions”The editor ships with step definitions that cover the most common test scenarios. You get these for free when you import from @portabletext/editor/test/vitest:
Given steps (setup):
Given the text "..."sets the editor contentGiven "strong" around "..."applies a decorator to textGiven a "link" "l1" around "..."applies an annotationGiven a global keymapsets up keyboard shortcut handling
When steps (actions):
When the editor is focusedfocuses the editorWhen "..." is typedtypes text character by characterWhen "..." is insertedinserts text all at once (mimics Android input)When "{Enter}" is pressedpresses a keyWhen "{Backspace}" is pressedpresses backspaceWhen "..." is selectedselects text in the editorWhen the caret is put after "..."positions the cursorWhen undo is performedtriggers undoWhen redo is performedtriggers redoWhen "link" is toggledtoggles an annotationWhen "strong" is toggledtoggles a decorator
Then steps (assertions):
Then the text is "..."asserts the editor contentThen "..." has marks "..."asserts marks on textThen "..." has no marksasserts no marks on textThen "..." is selectedasserts the current selection
These steps handle text blocks, marks, selections, undo/redo, and keyboard interactions. For custom block types or inline objects, you can add your own step definitions alongside the pre-built ones.
Real-world example: em dash input rule
Section titled “Real-world example: em dash input rule”Here’s how the official typography plugin tests its em dash behavior (converts -- to —):
Feature file (input-rule.em-dash.feature):
Feature: Em Dash Input Rule
Background: Given a global keymap
Scenario: Inserting em dash in unformatted text Given the text "-" When "-" is inserted Then the text is "—" When undo is performed Then the text is "--"
Scenario: Inserting em dash inside a decorator Given the text "foo-" And "strong" around "foo-" When the editor is focused And the caret is put after "foo-" And "-" is typed Then the text is "foo—" And "foo—" has marks "strong"Test file (input-rule.em-dash.test.tsx):
import {defineSchema} from '@portabletext/editor'import {parameterTypes} from '@portabletext/editor/test'import { createTestEditor, stepDefinitions, type Context,} from '@portabletext/editor/test/vitest'import {Before} from 'racejar'import {Feature} from 'racejar/vitest'import emDashFeature from './input-rule.em-dash.feature?raw'import {TypographyPlugin} from './plugin.typography'
Feature({ hooks: [ Before(async (context: Context) => { const {editor, locator} = await createTestEditor({ children: <TypographyPlugin />, schemaDefinition: defineSchema({ decorators: [{name: 'strong'}], annotations: [{name: 'link'}], }), })
context.locator = locator context.editor = editor }), ], featureText: emDashFeature, stepDefinitions, parameterTypes,})The test file is 20 lines. The feature file describes the behavior in plain English. The pre-built step definitions do the heavy lifting.
Adding custom step definitions
Section titled “Adding custom step definitions”If your behavior involves custom block types or domain-specific assertions, add your own step definitions alongside the pre-built ones:
import {stepDefinitions as builtInSteps} from '@portabletext/editor/test/vitest'import {Given, Then, When} from 'racejar'
const customSteps = [ Given('an image block with src {string}', async (context, src) => { // Set up editor with an image block }), Then('the image src is {string}', async (context, expectedSrc) => { // Assert image block data }),]
Feature({ // ... stepDefinitions: [...builtInSteps, ...customSteps],})Racejar will error if a step definition is missing or if you define duplicates.