こんにちは、フロントエンドエンジニアの桐澤(@kiririLee)です。Tiptapで開発したエディターをどのようにテストしているかについて書きます。
昨年末に2024年版のPR TIMESエディターテスト戦略についてまとめられたブログが投稿されました。フロントエンドチームではエディターのテストを日々模索しており、その過程で新たに書いたテストパターンを紹介します。

詳細は後の章で深掘りしていきますが、はじめに今回紹介する3つの主なテストをざっくりとまとめます。
- Playwright によるテスト
エディターとReactコンポーネントの連携部分を包括的にテストします。テスト時間の増加と不安定な挙動が伴うテストです。 - Vitest Browser Mode を使ったテスト
エディターの個別機能を細かく検証します。Tiptapの拡張機能による特定の動作やショートカット機能を効率的にテストできます。 - Editorインスタンス を使ったテスト
エディターとReactとの連携を小さい粒度でテストします。エディター部分のテスト分割が可能になり、テストの安定性を高めます。
この3つのアプローチは、それぞれ強みがありエディターの堅牢なテスト戦略を構築する上で互いに補完し合います。次章では、各テスト手法の具体的な使用例とそのメリットについて、もう少し具体的に掘り下げていきます。
Playwrightによるテスト
Playwright では以下をテストしています。具体的な話は先ほど紹介したブログに書かれているためこの章はまとめる程度に留めます。
- ブラウザ上でユーザーの一連の行動をシミュレートし、主要機能が動作することを確認するテスト
- Tiptap によるエディター機能と React のコンポーネントが連携する箇所のテスト
一つ目に関しては、管理画面のプレスリリースエディター(入稿画面)に訪れてから配信までするようなテストで、プレスリリース配信を提供する上で絶対に外せないようなテストケースを想定しています。例えば以下のようなテストケースです。
- プレスリリースが配信できること
- プレスリリースが予約配信できること
- プレスリリースが共有できること
- プレスリリースを月に31件以上配信した場合はモーダルが表示されること
- テンプレートから新規作成でプレスリリースが作成できること
二つ目に関しては、エディター上にある太文字にカーソルを合わせたらReactコンポーネントであるBoldアイコンがenable状態の見た目になるようなケースで、Tiptapエディター上での操作がReactコンポーネントまで波及する動きのテストケースを想定しています。
二つ目のテストでは「エディター上の動作」と「その動作に紐づくコンポーネント」のみがテスト対象となっており、一つ目のテストと比べて確認したい範囲は狭いです。しかし、Pageコンポーネントのようなより上位レベルのコンポーネントを完全にモックする必要があるなど、コンポーネントの構造的な理由からテスト対象を絞り込むことが困難でした。また、Tiptapエディター上のテキストを太文字に変えるような動きは jsdom 環境ではエミュレートしきれない部分があったため、テスト環境にブラウザ環境を使う必要もありました。そのため二つ目のテストも Playwright で書いています。
Playwright のテストに関してさらに詳しい情報は先ほど紹介したブログをご覧ください。
Vitest Browser Mode を使ったテスト
Vitest Browser Mode を使用して Extension の単体テストを書いています。ブラウザ環境でテストできるため Vitest のテストファイル上でTiptapのエディターインスタンスやメソッドをモックなしでそのまま使うことができます。
以下はエディター上でリンク機能を管理する LinkExtension のテストです。URL を入力した後に半角スペースを入力するとURL が自動でリンク化される仕様をテストしている例です。userEvent は @vitest/browser/context パッケージを使用しています。renderTiptapEditor については後述します。
import {describe, it, expect} from 'vitest';
import {userEvent} from '@vitest/browser/context';
import {StarterKit} from '@tiptap/starter-kit';
import {type Editor} from '@tiptap/core';
import {renderTiptapEditor} from 'path/to/render-tiptap-editor';
import {LinkExtension} from '/path/to/LinkExtension';
...
it('URLを入力後半角スペースを入れることで自動でリンク化すること', async () => {
const extensions = [StarterKit, LinkExtension];
function setUpEditor(editor: Editor) {
/** エディターにフォーカスを当てる */
editor.chain().focus().run();
}
const {getByRole} = await renderTiptapEditor({
extensions,
content: '',
setUpEditor,
});
// テキストの入力
await userEvent.keyboard('https://example.com');
// 半角スペースを入力
await userEvent.keyboard('{Space}');
// URLがリンクになっていること
expect(getByRole('link', {name: 'https://example.com'})).toBeVisible();
});
...以下の動画は URL の後に半角スペースを入力してリンク化している様子です。
半角スペースを入力して URL をリンク化する処理は LinkExtension の addKeybordShortcuts で実装されています。
export const LinkExtension = Link.extend({
addKeyboardShortcuts() {
return {
Space({editor}) {
....
...先ほど登場した renderTiptapEditor のコードについて紹介します。
/*
renderTiptapEditor はエディターを任意のコンテンツとExtensionで
初期化してブラウザ上に展開します。
エディターの初期化はTiptapのuseEditor、
ブラウザ上への展開は@testing-library/reactのrenderで行います。
*/
export async function renderTiptapEditor({
content,
extensions,
setUpEditor,
}: RenderTiptapEditor) {
const waitForEditorSetupComplete = vi.fn();
const editorOperation = (editor: Editor) => {
setUpEditor(editor);
waitForEditorSetupComplete();
};
const renderResult = render(
<TiptapEditor
content={content}
extensions={extensions}
setUpEditor={editorOperation}
/>,
);
/*
render 実行後、editorのカーソル操作などを行う setUpEditor の実行完了を待ちます。
これは setUpEditor でエディター初期化後にテストのためにエディター上で
行いたい動作を行うまで待っています。
具体例は後述しますが、初期化時に特定のポジションにカーソルを合わせる時などに使用します。
*/
await waitFor(() => {
expect(waitForEditorSetupComplete).toHaveBeenCalled();
});
/* render の実行結果である renderResult をそのまま返します。*/
return renderResult;
}
function TiptapEditor({content, extensions, setUpEditor}: RenderTiptapEditor) {
const editor = useEditor({
extensions,
content,
onCreate({editor}) {
setUpEditor(editor);
},
});
if (editor === null) {
return null;
}
return <EditorContent editor={editor} id='editor' />;
}前述したLinkExtensionのテスト例をもう一度示します。ここまでに説明した内容をコメントに載せています。
/*
renderTiptapEditor は @testing-library/react の render の実行結果が
そのまま返ってくるためReactコンポーネントのテストを行う時と同じように
エディター上の要素を取得しアサートできます。
*/
...
it('URLを入力後半角スペースを入れることで自動でリンク化すること', async () => {
/*
extensions には StarterKit とテスト対象の Extension を配列で構成します。
StarterKit は DocumentExtension や ParagraphExtension など他機能のベースとなる
基本的な機能を提供しますが、どの Extension のテストでも大体必要になるため
テスト対象のExtensionと同時に構成しています。
*/
const extensions = [StarterKit, LinkExtension];
/*
setUpEditor はコールバック関数の引数としてテスト用に初期化したエディターインスタンスを
受け取れるため、インスタンスのメソッドを使用して初期化後のエディターに対して
テスト実行前に何かしら操作を行えます。
このテストではエディターにテキストを入力するためにテキスト入力前にエディターにフォーカスを
当てるようにしています。
*/
function setUpEditor(editor: Editor) {
/** エディターにフォーカスを当てる */
editor.chain().focus().run();
}
const {getByRole} = await renderTiptapEditor({
extensions,
content: '',
setUpEditor,
});
// テキストの入力
await userEvent.keyboard('https://example.com');
// 半角スペースを入力
await userEvent.keyboard('{Space}');
// URLがリンクになっていること
expect(getByRole('link', {name: 'https://example.com'})).toBeVisible();
});
...URL を半角スペースを使って自動的にリンク化する機能はシンプルなように見えますが、エディター上でのイベントやユーザー操作のパターンは非常に多いため、多くのテストケースをカバーする必要があります。バグが見つかり修正された後、リグレッションを防ぐために書きたいテストケースも多いです。しかし、Playwright を使って1ページ全体を動かすテストは、先に紹介したブログにあるように、実行時間の増加と Flaky さを持ち込みテストケースが増やしにくいです。
紹介した Vitest Browser Mode による Extension 単位のテストはこうした事情をある程度吸収してくれます。ちなみにLinkExtension に関して他にも以下のようなテストケースが書かれています。
- URLと文字の間にスペースがある場合はリンク化しないこと
- URLと文字の間にスペースがない場合はリンク化されること
- URLの直後で半角スペースを押したもののみ自動でリンク化すること
- すでにリンク化されているものの直後でスペースを押しても何も起きないこと
- URLを入力後改行して文字を入力しスペースを押しても何も起こらないこと
テストの例をもう一つ紹介します。
PR TIMES のプレスリリースエディターにはボタン機能があります。ボタン機能はプレスリリース上にボタンの見た目をしたリンクを配置することができる機能です。通常のリンクより目立たせられるため、申し込みフォームへのリンクやキャンペーン詳細ページへのリンクを配置する用途でよく使われています。
ボタン機能について詳しくはこちらの記事をご覧ください。
https://prtimes.jp/magazine/press-release-new-editor/#chap_h49snlxm
以下にボタン機能のテストを示します。エディター上にボタンを配置後、ボタンに付与される URL が編集できることをテストしています。ボタンを編集するための NodeView がエディター上で展開されることや parseHTML・renderHTML によりcontent で指定した HTML がボタン機能として読み込まれることも透過的にテストしています。
test('詳細を見るボタン: エディター上でURLが編集できること', async () => {
/* ボタン機能をテストするためボタン機能を処理する PressReleaseButtonExtension を指定します */
const extensions = [StarterKit, PressReleaseButtonExtension];
function setUpEditor() {
// 何もしない
}
const {getByRole} = await renderTiptapEditor({
extensions,
/*
ボタン挿入後のHTML構造を指定しています。このHTMLがTiptapにより読み込まれ、
PressReleaseButtonExtension によって処理されエディター上でボタン機能が有効になります。
*/
content: `<div class="pr-button"><a href="https://prtimes.jp/" rel="nofollow ugc noopener" target="_blank" data-type="details">詳細を見る</a></div>`,
setUpEditor,
});
/*
Extension の NodeView で設定しているURL変更用のフォーム(Reactコンポーネント)を表示する
*/
await userEvent.click(getByRole('button', {name: 'ボタンを編集する'}));
// 詳細を見るボタンの編集画面でURLを変更する
const urlInput = getByRole('textbox', {name: 'URLを入力'});
await userEvent.clear(urlInput);
await userEvent.type(urlInput, 'https://example.com/');
await userEvent.click(getByRole('button', {name: '適用'}));
// エディター上でURLが変更されていること
expect(getByRole('link', {name: '詳細を見る'}).getAttribute('href')).toBe(
'https://example.com/',
);
});以下にテストしている内容をイメージした動画を示します。動画では一番最初にボタンを挿入していますが、例で示したテストでテストしたい内容は「URLを編集できること」なのでボタンを挿入するステップはテストコードでは content にボタン挿入後の HTML 構造を指定することでスキップしています。
ボタンを編集する際のUIは、NodeView を使用してReactコンポーネントをエディター上に展開しています。以下のようにボタン機能の NodeView は Extension に紐づいています。
export const PressReleaseButtonExtension = Node.create({
name: 'pr_button',
...
addAttributes() {
...
},
parseHTML() {
...
},
renderHTML({HTMLAttributes}) {
...
},
addNodeView() {
return ReactNodeViewRenderer(PressReleaseButtonNodeView);
},以上 NodeView を使用した Extension をテストするケースの紹介でした。
この章では Vitest Browser Mode を使用したテストを紹介しました。この方法はテストケースを増やしやすく、実行時間とFlakyさが抑えられるため、エディター上での振る舞いをテストするのに有効な方法だと思います。Extension と React コンポーネントが連携するようなテストは前述した Playwright のテストに頼る必要がありますが、Extension の単体テストでは積極的に使っていけます。
Editorインスタンスを使ったテスト
最後に紹介するのは、Editorインスタンスを使用したテストです。この方法は、Tiptap のコンテンツと React を連携させる機能をテストする場面に役立ちます。
一番最初の章で紹介した Playwright によるテストについても Tiptap と React を連携させる部分をテストしています。このPlaywright のテストはエディターの動きが React まで反映され、連携部分の一連の動きが通しでテストできます。
しかし、先に述べたように、実行時間が増加し Flakyさを考慮する必要があるため、Playwright のテストは可能な限り最小限に抑えたいです。そして、そうするためにはテスト範囲を細分化し、より単体テストに近いテストを充実させ、ボトムアップで振る舞いを担保していく必要があります。
この章は、エディターと React の連携部分からエディターの振る舞いのみを絞ってテストする方法を紹介します(React側の通常のコンポーネントテストは広く知られているためこのブログでは触れません)。
実際には Editor インスタンスを受け取って React コンポーネント用のオブジェクトを返す関数を定義してテストできるようにしています。以下はエディター上のコンテンツを全て走査し、画像機能の Extension から PressReleaseImage というオブジェクトを構成する関数の例です。
import {type Editor} from '@tiptap/core';
type PressReleaseImage = {
fileName: string;
};
export function extractImagesFromTiptapEditorContent(
editor: Editor,
): PressReleaseImage[] {
const images: PressReleaseImage[] = [];
editor.state.doc.forEach((node) => {
switch (node.type.name) {
case ExampleImageExtension1.name:
....
images.push(getPressReleaseImageFromExampleImageExtension1(ExampleImageExtension1));
case ExampleImageExtension2.name:
...
images.push(getPressReleaseImageFromExampleImageExtension2(ExampleImageExtension2))
...
}
return images;
}このような関数は以下のようにテストを書いています。@tiptap/coreから export される Editorインスタンス の content で大画像機能を表現するHTML構造を指定し、テスト対象である extractImagesFromTiptapEditorContent により data-filename 属性の値が取得できることをテストしています。
createEditorElement でDOMにエディターをバインドする処理や editor.destroy によりエディター上のイベントを解除する処理はTiptapのレポジトリにあるテストを参考にしました。
import {Editor} from '@tiptap/core';
const editorElementClass = 'tiptap';
let editor: Editor | null = null;
const createEditorElement = () => {
const editorElement = document.createElement('div');
editorElement.classList.add(editorElementClass);
document.body.append(editorElement);
return editorElement;
};
const getEditorElement = () => document.querySelector(`.${editorElementClass}`);
describe('大画像がTiptapのエディター上から取得できること', () => {
afterEach(() => {
editor?.destroy();
getEditorElement()?.remove();
});
test('JPG画像をアップロードした大画像がTiptapのエディター上に存在する時、その大画像のfileNameが取得できること', () => {
const expectedFileName = 'd37235-4352-775d49ebcbf6fe93c300-0.jpg';
editor = new Editor({
element: createEditorElement(),
extensions,
content: `<div class="pr-img"><figure class="pr-img__item--large"><img src="/path/to/image" data-filename="${expectedFileName}"><figcaption class="pr-img__item__caption"></figcaption></figure></div>`,
});
const image = extractImagesFromTiptapEditorContent(editor);
expect(image.length).toBe(1);
expect(image[0].fileName).toBe(expectedFileName);
});ちなみに、このテストは jsdom でエミュレート可能な範囲に収まっているため、jsdom環境でも動作します。前述した Vitest Browser Mode でテストしている LinkExtension のテストを jsdom 環境で実行すると以下のように落ちます。これは jsdom が getClientRects を実装していないためです。
jsdom が「featureタグ」でマークしている機能は実装されておらず、ポリフィルによるモックが必要になります。しかし、このテストで対象としている関数は、内部処理でHTMLからエディター機能への変換のみをTiptapが行っており、jsdom で実装されていないメソッドを使用していません。したがって、jsdom環境でも問題なく動作します。
Editorインスタンスでテストできる範囲に焦点を当てることで、テスト対象となる関数の処理の複雑さが軽減されます。

以上Editorインスタンスを使用したテストでした。
まとめ
紹介した3つのテストは以下のように使い分けできます。それぞれのポイントも載せます。
- Playwright によるテスト
- Tiptap エディターと React コンポーネントの連携部分を一連の動きを通してテストする場合に使える。
- 実行時間の増加とFlakyさを考慮する必要がある。そのためできるだけ下二つのテストとReactコンポーネントのテストでカバーしていきたい。
- Vitest Browser Mode を使ったテスト
- Tiptap の Extension 単位(エディターの機能単位)でエディター上での振る舞いをテストする場合に使える。
- NodeView や addKeybordshortcuts など Extension に紐づく機能が、文字入力やエンターなどのブラウザ上での操作を含めて単位テストで書ける。
- テストケースを増やしやすい。
- Editorインスタンスを使用したテスト
- Tiptap エディターと React コンポーネントが連携する際のバブとなるEditorコンテンツを参照する関数をテストする場合に使える。
- テスト対象の関数の関心をより小さい境界で絞ってテストを書く必要がある。
- テストケースを増やしやすく連携部分のテストをボトムアップで充実させられる。
途中でも出てきた話ですがエディター上で発生するイベントやユーザー操作のパターンは非常に多いです。WYSIWYGエディターと称されるようにエディター上での体験においてインタラクティブな操作感も求められます。そんな状況下のためテストしたい項目も多くなるかと思いますが、今回紹介したテスト手法を使えば意外と幅広くテストが書けるのではないでしょうか。2024年に続いて2025年も引き続きエディターのテスト戦略を模索していきたいと思います。

