Skip to content

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:

global.d.ts
declare module '*.feature?raw' {
const content: string
export default content
}

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:

  • createTestEditor spins up a real editor in the browser. Returns {editor, locator} where editor is the editor instance and locator is a Playwright locator for user interactions.
  • getTersePt from @portabletext/test gives you a compact representation of the editor content for assertions. ['foo ,bar, baz'] means one block with three spans (commas separate spans).
  • editor.getSnapshot().context gives you the full editor state including value (the Portable Text array) and selection.
  • vi.waitFor is needed because editor updates are asynchronous.

For comprehensive behavior specs, write Gherkin feature files and run them with Racejar. This is how every official plugin is tested.

Create a .feature file that describes your behavior in plain English:

my-behavior.feature
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)

Create a test file that connects your feature file to the editor:

my-behavior.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 {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.

Terminal window
npx vitest

Each Gherkin scenario becomes a separate test case. Failures show which step failed and what the editor state was at that point.

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 content
  • Given "strong" around "..." applies a decorator to text
  • Given a "link" "l1" around "..." applies an annotation
  • Given a global keymap sets up keyboard shortcut handling

When steps (actions):

  • When the editor is focused focuses the editor
  • When "..." is typed types text character by character
  • When "..." is inserted inserts text all at once (mimics Android input)
  • When "{Enter}" is pressed presses a key
  • When "{Backspace}" is pressed presses backspace
  • When "..." is selected selects text in the editor
  • When the caret is put after "..." positions the cursor
  • When undo is performed triggers undo
  • When redo is performed triggers redo
  • When "link" is toggled toggles an annotation
  • When "strong" is toggled toggles a decorator

Then steps (assertions):

  • Then the text is "..." asserts the editor content
  • Then "..." has marks "..." asserts marks on text
  • Then "..." has no marks asserts no marks on text
  • Then "..." is selected asserts 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.

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.

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.