こんにちは。PR TIMESでフロントエンドエンジニアをしている夛田(@unachang113)です。
みなさん、フロントエンドのテストって書いてますか?
今回は自分が所属しているエディターチームのエディターに関するフロントエンドのテスト戦略について書こうと思います。
エディターについて
PR TIMESでは、プレスリリースを入稿するためのエディターを提供していて、2023年の12月にUI刷新を行いました。

エディターは、2024年12月現在、React、Tiptap(headlessエディターライブラリ)、TypeScriptという技術スタックで開発を行っています。
エディターは本文の入力部分(以下、本文エディターと称します)以外にも、ツールバー・サイドバーなど複数のUIが組み合わさって構成されていて、各UIから本文エディターに対してTiptapのコマンド関数を実行したり操作を行っています。
エディターのテストについて
エディターという機能の性質上、前述の通り複数のUIコンポーネントを組み合わせたテストが必要になることや、Tiptapを使用している箇所のテストが動作しないというdiscussionも上がっており、VitestでbrowserModeが登場するまでユニットテストが書きづらい状況でした。
TiptapのDiscussionにもUnit testが書けないという質問があがっています。
そのため、2024年現在、以下のようにケースによって手厚く書くテストの種類を分けてテストケースの作成を行っています。
- 本文エディターが絡む部分のテスト
→ Playwrightを用いたIntegration Testメイン - 本文エディターが絡まない部分(ツールバー、ヘッダー、サイドバーなどの各UI)のテスト
→ React・TypeScriptのみで動く部分に関してはVitestのUnit Testメイン、本文エディターとの連携まわりはPlaywrightによるIntegration Test

本記事では本文エディターが絡む部分のテストの話を書いていきたいと思います。
本文エディターが絡む部分のテスト
本文エディターのTiptapを使用している部分(主にExtensionというTiptap独自で作成できる拡張機能)はツールバーとエディターの連携など複数のUIの機能をかけ合わせた確認が多いため、現状はPlaywrightを用いたIntegration Testをメインで自動テストを書いています。
ただ、入力周りのテストがFlakyになりやすいことや、テストの実行時間の観点から網羅的なテストを書くというより、ハッピーパスを担保するためのテストを書くようにしているというのが現状です。
PlaywrightのIntegration Testの戦略
1. POM(Page object models)を利用し、同じ処理を関数から呼び出すようにする
例えば、APIのモックやテキストの入力など、テストが増えるにつれて同じ記述を何度も書くケースがあります。エディターのIntegration Testが徐々に増えてきて、全く同じ動作に対する記述を愚直に書いているテストケースが増えてきたため、POM(Page object models)を使用し、エディターの各動作のテストスイートの共通化を徐々に実施しています。
元のテストコードとPOMに変換したテストコードの例は以下です。
<元のテストコード>
test.describe('再校正が実施できること', () => {
let page: Page;
test.beforeEach(async ({browser, baseURL}) => {
// Cookie等の設定
page = await setUsePressReleaseEditorV3dot2(browser, baseURL!);
// newRelicのモック
await mockNewRelic(page);
// ===== API の mock =====
await page.route('/api/press_kit_edit.php/company_user/me', async (route) =>
route.fulfill({json: getMeResponseBody}),
);
...
// 校正用APIのmock
await page.route('/api/proofreading.php/main', async (route) =>
route.fulfill({json: postProofreadingMainResponseBody}),
);
...
// プレスリリース新規作成画面にアクセス
await page.goto('/my_c3/action.php?run=mypage&page=pressreleaseregist');
await page.waitForURL(
'/my_c3/action.php?run=mypage&page=pressreleaseregist',
{waitUntil: 'domcontentloaded'},
);
});
test('再校正が実施できること', async () => {
...
});
}); <POMに変更したテストコード>
test.describe('再校正が実施できること', () => {
let editor: SetEditorPage;
test.beforeEach(async ({browser, baseURL}) => {
// Cookie等の設定
const page = await setUsePressReleaseEditorV3dot2(browser, baseURL!);
// POM用のClassの呼び出し
editor = new SetEditorPage(page);
// ===== API の mock =====
// 基本のエディター用APIのモック
await editor.setEditorApiMock();
// 校正用APIのmock
await editor.proofreading.setProofreadingApiMock({
mainResponseBody: postProofreadingMainResponseBody,
lintResponseBody: getProofreadingLintResultsEmptyResponseBody,
});
// プレスリリース新規作成画面にアクセス
await editor.gotoEditorRegisterPage();
});
test('再校正が実施できること', async () => {
...
});
});
上記のようにPOMに変更したコードだとtest.beforeEachの中で愚直にAPIのモックの呼び出しがなくなるため、コード量が減ったのと、関数名でどういう処理を実施しているのかがわかるため、コードが読みやすくなりました。
POMにしたことによるその他の恩恵としては、以下のものがあります。
- コードで実行される処理が共通化されているため、誰でも簡単に面倒な操作のテストが書けるようになった
- 機能が削除された場合にgrepして一括で動作に関する関数を削除すればよくなったので楽
- 当社は新機能実装の際にFeatureFlagを用いて機能を隠蔽することがあり、この設定を一つ関数を変えるだけで全部のテストファイルに適用できるようになった
- めんどくさいキーボード周りの細かい操作をPlaywrightで毎回書かなくてよくなった
2.Flakyなテストに立ち向かう
Playwrightのテストは文字入力周りが特にFlakyになりやすいです。
エディターでよくあるfailするケースとしては、カーソル位置が正しく設定されず、テキストの入力位置のズレが生じ、VRTで正しい挙動のsnapshotが撮れずfailするケースがあります。
<よくあるFlakyになりやすいテスト例>
import {expect, test} from '@playwright/test';
import {SetEditorPage} from '@/tests/press-release-editor-v3/common/setup/set-editor-page';
import {
getProofreadingLintResultsEmptyResponseBody,
getProofreadingLintResultsDuplicateResponseBody,
} from '@/tests/press-release-editor-v3/common/data/response/proofreading-lint-results';
import {
postProofreadingMainResultDuplicateEmptyResponseBody,
postProofreadingMainResponseBody,
} from '@/tests/press-release-editor-v3/common/data/response/proofreading-main';
import {setUsePressReleaseEditorV3dot2} from '@/tests/press-release-editor-v3/common/setup/set-use-press-release-editor-v3-2';
test.describe('校正のテキストを削除・編集したときの本文テキストのハイライト挙動', () => {
let editor: SetEditorPage;
test.beforeEach(async ({browser, baseURL}) => {
const page = await setUsePressReleaseEditorV3dot2(browser, baseURL!);
editor = new SetEditorPage(page);
...
});
test('校正箇所のテキストの間(五輪だと五と輪の間)にテキストを入力した場合にハイライトが消えないこと', async () => {
// テキストを入力
await editor.setFillBody('五輪が開催されました。');
// 校正ボタンをクリック
await editor.proofreading.moveToProofreadingMode(1);
// カーソルが五|輪の状態になるまで移動する
await editor.proofreading.clickProofreadingText('五輪');
await editor.pressArrowUp();
await editor.pressArrowRight();
// カーソルが五|輪の状態で間にaを入れる
await editor.inputText('a');
// キーボード操作を行ったので少し待つ ←Flakyさ回避のための処理
await editor.waitForTimeout(500);
// 五a輪にハイライトが当たっているか確認
expect(
await editor.proofreading.proofreadingEditorBodyElement
.getByText('五a輪', {exact: true})
.evaluate((node) => node.tagName),
).toBe('PROOFREADING');
// snapshotを撮影←ここで五輪aになったりするケースが有り、Flaky
await expect(editor.page).toHaveScreenshot(
'校正箇所のテキストの間(五輪だと五と輪の間)にテキストを入力した場合にハイライトが消えない.png',
{
fullPage: true,
},
);
});
});今は入力した後にwaitForTimeOut やwaitForSelectorを使って操作を待機することでFlakyさを回避するようにしています。
<WaitForTimeOutを利用した例>
test.describe('校正のテキストを削除・編集したときの本文テキストのハイライト挙動', () => {
let editor: SetEditorPage;
test.beforeEach(async ({browser, baseURL}) => {
...
});
test('校正箇所のテキストの間(五輪だと五と輪の間)にテキストを入力した場合にハイライトが消えないこと', async () => {
// テキストを入力
await editor.setFillBody('五輪が開催されました。');
// 校正ボタンをクリック
await editor.proofreading.moveToProofreadingMode(1);
// カーソルが五|輪の状態になるまで移動する
await editor.proofreading.clickProofreadingText('五輪');
await editor.pressArrowUp();
await editor.pressArrowRight();
// カーソルが五|輪の状態で間にaを入れる
await editor.inputText('a');
// キーボード操作を行ったので少し待つ ←Flakyさ回避のための処理
await editor.waitForTimeout();
// 五a輪にハイライトが当たっているか確認
expect(
await editor.proofreading.proofreadingEditorBodyElement
.getByText('五a輪', {exact: true})
.evaluate((node) => node.tagName),
).toBe('PROOFREADING');
// snapshotを撮影(ここで五輪aになったりするケースが有る)
await expect(editor.page).toHaveScreenshot(
'校正箇所のテキストの間(五輪だと五と輪の間)にテキストを入力した場合にハイライトが消えない.png',
{
fullPage: true,
},
);
});
});/** POMの関数郡用Class */
export class SetEditorPage {
...
/**
* 動作を待機する
*/
async waitForTimeout(ms = 500) {
await this.page.waitForTimeout(ms);
}
}3. 不必要なVRTを減らす
PlaywrightによるIntegration Testはテストの実行時間が遅く、遅い要因となる原因の一つがVRTのsnapshotです。VRTは変更が画像で視覚的にわかるため、安心さはありますが、snapshotを撮れば撮るだけテストの実行時間が遅くなります。
テストを書き始めた頃は安心や他のテストケースで全てVRTを撮っているからという理由で特に方針も無くVRTを撮っていましたが、PullRequestを出した際にCIがtimeoutでfailしたり、Playwrightのテストの実行時間が20分を超えたりと実行時間がネックになり始めました。また、VRTはFlakyになりやすく、何もUIコンポーネントの編集をしてないのにテストがfailする等ストレスが貯まり開発者体験が落ちてしまう要因にもつながっていました。
そこで現在、不要なVRTの精査を行い、必要のないsnapshotを削除するようにしています。
<不要な一例>
test.describe('校正のテキストを削除・編集したときの本文テキストのハイライト挙動', () => {
let editor: SetEditorPage;
test.beforeEach(async ({browser, baseURL}) => {
...
});
test('校正箇所のテキストの間(五輪だと五と輪の間)にテキストを入力した場合にハイライトが消えないこと', async () => {
// テキストを入力
await editor.setFillBody('五輪が開催されました。');
// 校正ボタンをクリック
await editor.proofreading.moveToProofreadingMode(1);
// カーソルが五|輪の状態になるまで移動する
await editor.proofreading.clickProofreadingText('五輪');
await editor.pressArrowUp();
await editor.pressArrowRight();
// カーソルが五|輪の状態で間にaを入れる
await editor.inputText('a');
// キーボード操作を行ったので少し待つ ←Flakyさ回避のための処理
await editor.waitForTimeout(500);
// 五a輪にハイライトが当たっているか確認
expect(
await editor.proofreading.proofreadingEditorBodyElement
.getByText('五a輪', {exact: true})
.evaluate((node) => node.tagName),
).toBe('PROOFREADING');
// snapshotを撮影(ここで五輪aになったりするケースが有る)
await expect(editor.page).toHaveScreenshot(
'校正箇所のテキストの間(五輪だと五と輪の間)にテキストを入力した場合にハイライトが消えない.png',
{
fullPage: true,
},
);
});
});
例えばこの例だと、五a輪というテキストにハイライトがあたっているかは以下のアサーションで担保できているため、わざわざSnapshotを撮らなくても動作が正しいことが保証されています。
// 五a輪にハイライトが当たっているか確認←このassersionでSnapshotで確認したい内容は担保できる
expect(
await editor.proofreading.proofreadingEditorBodyElement
.getByText('五a輪', {exact: true})
.evaluate((node) => node.tagName),
).toBe('PROOFREADING');展望
1. VitestにBrowser Modeが出たので本文エディターのテストを移せるか検証中
ツールバーやサイドバーが絡まず本文エディターのみで完結できる箇所を移せないか検証しています。
ただ、Browser ModeもsnapshotでFlakyになるケースがあり、あまり移行できてない状態です。
2. テストケースがない機能がまだあるので拡充していく
新エディターに移行する際に新規で追加された機能に関してはテストが書けている部分が多いのですが、テストが全然書けていない箇所がまだまだ多いので、テストが書かれている機能を拡充していきたいと思っています。
2025/02/20追記
Vitest Browser Mode・Editorインスタンスを用いたのテスト方法に関する記事を桐澤さんが投稿したので、こちらも合わせてご覧ください

まとめ
今回はPR TIMESのプレスキット作成時の本文エディターのテストについて紹介しました。
まだFlakyなテストや改善できそうな箇所は数多くあるので、改善して安全にプロダクト開発ができるように頑張っていこうと思います。
We are hiring!
一緒にPR TIMESの開発を担ってくれるエンジニアはもちろん、各種ポジションで採用を行っています!

