最終回

無料公開中公開翌日4時まで

codegenの記録を、保守性の高いE2Eテストコードに仕上げる

codegenで記録したE2Eテストコードは、そのままでは保守性に課題が残ります。Playwright MCPで実際のページを確かめながらロケーターにコンテナを付け、test.stepで処理をまとめる補正を、Claude Codeのスキルとして定義する方法を解説します。

(701号)

目次
シェア

はじめに

前回は、操作の複雑なSPAを対象に、Playwrightのcodegenでブラウザでの操作をテストコードとして記録しました。記録したコードはテストとして問題なく動作しましたが、保守性の面では次の2つの問題が残りました。

  • ロケーターがコンテナで絞り込まれておらず、同じラベルの要素が増えると壊れやすい
  • 操作が記録した順に並ぶだけで処理のまとまりがなく、何を確かめているテストなのかが読み取りにくい

今回は、記録されたコードをPlaywright MCPとClaude Codeで補正し、保守性の高いテストコードに仕上げていきます。

Claude Codeによる補正の流れ

codegenで記録したコードには、先に挙げた2つの問題が必ず含まれます。それなら、記録から補正までの一連の処理をスキルとしてまとめておけば、指示の手間を省けるでしょう。スキルに書く内容は、おおよそ次のようなものになります。

スキルに定義する内容のイメージ
引数で、テストファイル名と、test()に付けるテスト名を受け取る。

codegenを起動して、操作の記録を保存先のtmp.spec.tsに出力する。
記録が終わったら、tmp.spec.tsを読み込んで補正する。

【コンテナによる絞り込み】
(ここに、Playwright MCPで実際のページを確認しながらコンテナを補正する指示を書く)

【test.stepによる構造化】
(ここにtest.stepによる構造化の指示を書く)

補正したテストを実行し、壊れていないことを確かめる。
codegenが付けたテスト名を、引数で受け取ったテスト名に変える。
引数のファイル名で保存し、tmp.spec.tsは削除する。

それぞれの補正で何を指示すればよいのかを、順に見ていきましょう。

コンテナの付与

codegenが記録したロケーターは、記録した時点の画面で要素を1つに特定できさえすればよい、という書き方になっています。そのため、ロケーターで指定しているのと同じラベルの要素がほかのエリアに追加されると、テストが失敗するおそれがあります。そこで、各ロケーターに、対象の要素を含むエリア(コンテナ)で範囲を絞り込む補正を加えます。

ただし、補正の進め方は第1回とは異なります。第1回では、テストの内容をすべて言葉で指示し、Claudeにテストコードを一から生成させました。一方、今回は、人がcodegenで記録したコードをClaudeが補正する、手作業との組み合わせです。記録したコードは保守性に難があるものの、テストとしての動作は正確です。だからこそ、補正によってテストを壊さないことが何より大切になります。

この点を踏まえ、記録されたコードからロケーターを1つずつ取り出し、Playwright MCPを使って、そのロケーターが実際のページで指している要素を取得します。そのうえで、次の手順でコンテナを付けていきます。

  1. 対象の要素に目印を付け、祖先要素のクラスを集める
  2. 集めたクラスから、コンテナにするクラスを選ぶ
  3. コンテナを付けたロケーターに書き換える
  4. 書き換えたロケーターが目印を付けた要素を指しているかを確かめ、違っていれば調整する
  5. 書き換えたロケーターで実際に操作し、画面を次の状態に進める

1つずつ詳しく見ていきます。

1. 祖先要素のクラスの収集

まず、記録されたロケーターをそのまま使って対象の要素を取得し、evaluate()でその要素に対する処理を実行させます。evaluate()に渡した関数はブラウザの中で実行されるため、実際のDOMを直接調べられます。たとえば、「ページ追加」ボタンであれば次のとおりです。

祖先要素のクラスを集める
const path = await page
  .getByRole('button', { name: 'ページ追加' })
  .evaluate((domEl) => {
    domEl.setAttribute('data-e2e-target', 'true');
    const path = [];
    let current = domEl;
    while (current && current.tagName) {
      path.push({
        tag: current.tagName.toLowerCase(),
        classes: Array.from(current.classList),
      });
      current = current.parentElement;
    }
    return path;
  });

このコードでは、2つのことを行っています。1つは、対象の要素にdata-e2e-target属性を付けることです。これは、書き換えたロケーターが元と同じ要素を指しているかを、あとで確かめるための目印になります。もう1つは、対象の要素からhtml要素まで親をたどり、それぞれのタグ名とクラス名を集めることです。集めた結果は、対象の要素に近い順に並んだ配列になります。

2. コンテナにするクラスの選定

集めたクラス名の中から、コンテナにするクラスを選びます。基準は、そのクラスが画面のエリアを表しているかどうかです。

  • 採用する:サイドバー、メインエリア、ヘッダーなど、画面の領域を表すクラス
  • 除外する:レイアウトの実装手段(flex、gridなど)、状態(is-activeなど)、部品の見た目(btn、cardなど)、範囲が広すぎるラッパー(wrapper、appなど)

たとえば、「ページ追加」ボタンについて、次のような結果が得られたとします。

祖先要素のクラスを集めた結果(例)
[
  { tag: 'button', classes: ['btn', 'btn-primary'] },
  { tag: 'div', classes: ['flex', 'flex-col'] },
  { tag: 'nav', classes: ['sidebar'] },
  { tag: 'div', classes: ['app'] },
  { tag: 'body', classes: [] },
  { tag: 'html', classes: [] },
]

この場合、btnやflexはエリアを表しておらず、appは範囲が広すぎるので、いずれも除外します。残ったsidebarがコンテナになります。候補が複数残った場合は、配列の先頭に近いもの、つまり対象の要素にもっとも近い内側のクラスを採用します。これは、外側のクラスほど範囲が広く、絞り込みの効果が小さくなるからです。

どのクラスも当てはまらない場合は、コンテナを付けずに次の検証へ進みます。

3. ロケーターの書き換え

コンテナとなるクラス名が決まったら、元のロケーターの先頭に付けて書き換えます。記録された時点では、次のとおりでした。

コンテナを付ける前
await page.getByRole('button', { name: 'ページ追加' }).click();

これに、選んだsidebarクラスをコンテナとして付けると、次のようになります。

コンテナを付けたあと
await page
  .locator('.sidebar')
  .getByRole('button', { name: 'ページ追加' })
  .click();

元のロケーターには手を加えず、その手前に.locator('.sidebar')を足しただけです。ボタンの特定方法は記録したときのまま変えず、探す範囲だけをサイドバーの中に絞っています。

4. 同一性の検証

コンテナを付けたら、書き換えたロケーターが元と同じ要素を指しているかを確かめます。範囲を絞っただけなので、ほとんどのロケーターは元と同じ要素を指します。例外は、前回記録したコードにもあった、nth()で何番目の要素かを指定しているロケーターです。

nth()で番号を指定したロケーター
await page.getByText('ここに追加').nth(1).click();

nth()の番号は、探す範囲の中で数えた順番です。コンテナで範囲を絞ると、範囲の外にあった候補は数えられなくなります。そのため、同じnth(1)のままでは、番号がずれて別の要素を指したり、該当する要素が見つからなくなったりすることがあります。

そこで、コンテナで絞った範囲の候補をまとめて調べ、最初に付けた目印(data-e2e-target属性)を持つ要素が何番目にあるかを求めます。

目印を持つ要素の番号を求める
const index = await page
  .locator('.main-area')
  .getByText('ここに追加')
  .evaluateAll((els) =>
    els.findIndex((el) => el.hasAttribute('data-e2e-target')),
  );

evaluateAll()は、ロケーターに当てはまるすべての要素を配列として受け取ります。この中から目印を持つ要素を探し、その位置を返しています。求めた番号が元の1と違っていれば、nth()の番号をその値に書き換えます。

5. 補正したロケーターでの操作

番号が確定したら、付けておいた目印を外す処置をします。目印は確認のために一時的に付けたものなので、ページには残さないようにするためです。

目印を外す
await page
  .locator('.main-area')
  .getByText('ここに追加')
  .nth(index)
  .evaluate((domEl) => domEl.removeAttribute('data-e2e-target')); // 目印を除去する

そのうえで、書き換えたロケーターを使って実際に操作します。この操作には2つの役割があります。1つは、補正したロケーターで本当に操作できるかを確かめることです。もう1つは、画面を次の状態に進めることです。SPAでは操作のたびにDOMが変わるので、ここで操作を進めておくことが、次のロケーターを調整する準備になります。

手順1〜5を記録されたコードの最後まで繰り返すと、ロケーターの補正が完了します。

test.stepによる構造化

もう1つの課題は、操作が記録した順に並ぶだけで、処理のまとまりがないことでした。前回記録したテストは、テスト用のページと画像部品を用意する準備、画像部品の複製を確かめる本題、ページを削除する後片付けの3つに分かれていました。しかし、コードからはその区切りが読み取れない状態でした。

そこで、記録されたコードの各操作が何のためのものかをClaudeに判断させ、意味のまとまりごとにグループ分けさせます。グループ分けには、Playwrightのtest.step()を使います。test.step()は、一連の操作と検証をひとまとめにして名前を付けられる仕組みで、次のように書きます。

test.step()の書き方
await test.step('ステップ名', async () => {
  // このステップで行う操作と検証
});

こうしてまとめておくと、まとまりごとの役割がステップ名として示されるので、何を確かめているテストなのかをコードから読み取れるようになります。

まとまりを区切るときの目印になるのはexpect()です。このテストで確かめたいのは複製なので、複製の操作から、それを確かめるexpect()までが本題になります。その前の、ページや画像部品を用意する操作が準備、後ろのページの削除が後片付けです。スキルでは、この考え方をもとに次のように指示します。

test.step()による構造化の指示
コンテナの付与が終わったコードを、test.step() で構造化する。

- テストで確かめたい expect() を見つけ、その結果を生む操作から expect() までを本題、その前を準備、後ろを後片付けとしてまとめる
  - 途中の状態を確認しているだけの expect()(準備が整ったことの確認など)は、テストで確かめたい expect() に当たらない
- ステップ名には、操作をなぞった説明ではなく、そのまとまりで何を確かめるのかを書く
- 準備と後片付けのステップ名には、頭に「準備:」「後片付け:」を付ける

この指示で構造化すると、テストの骨組みは次のようになります。

test.step()で構造化したテストの骨組み
test('画像部品を直下に複製できること', async ({ page }) => {
  await test.step('準備: テスト用ページに画像部品を追加する', async () => {
    /* ページを追加し、画像部品を配置する操作と検証 */
  });

  await test.step('画像部品を直下に複製できること', async () => {
    /* 複製の操作と、複製されたことの検証 */
  });

  await test.step('後片付け: テスト用ページを削除する', async () => {
    /* ページを削除する操作と検証 */
  });
});

スキルの呼び出しと補正結果

ここまで紹介してきた指示は、筆者が使っているスキルを記事用に調整したものです。スキル全体は、サンプルリポジトリに置いています。

呼び出すときは、テストファイル名とテスト名を渡します。

スキルの呼び出し
/e2e-test-maker-for-spa image-duplicate.spec.ts 画像部品を直下に複製できること

呼び出すとcodegenが起動するので、ブラウザでテストしたい操作を記録します。記録を終えると、Claudeがコンテナの付与と構造化を進め、最後に補正後のテストを実行して、補正によってテストが壊れていないことを確かめます。補正にはしばらく時間がかかるので、その間は別の作業を進めておくとよいでしょう。

前回と同じ操作を記録し、スキルに補正させた結果は次のとおりです。

補正されたテストコード
import { test, expect } from '@playwright/test';

test('画像部品を直下に複製できること', async ({ page }) => {
  await page.goto('http://localhost:9292/');

  await test.step('準備: テスト用ページに画像部品を追加する', async () => {
    await page.locator('.sidebar').getByRole('button', { name: 'ページ追加' }).click();
    await page.locator('.toolbar').getByRole('button', { name: '部品を追加' }).click();
    await page.locator('.main-area').getByText('ここに追加').nth(0).click();
    await page.locator('.parts-panel').getByText('画像', { exact: true }).click();
    await page.locator('.parts-panel').getByText('画像', { exact: true }).nth(2).click();
    await page.locator('.parts-panel').getByRole('button', { name: 'この部品を追加する' }).click();
    await page.locator('.main-area').getByRole('paragraph').filter({ hasText: '画像の説明を入力してください' }).click();
    await expect(page.locator('.main-area').getByRole('img')).toBeVisible();
  });

  await test.step('画像部品を直下に複製できること', async () => {
    await page.locator('.settings-panel').getByRole('button', { name: '複製' }).click();
    await page.locator('.settings-panel').getByText('直下に複製').click();
    await expect(page.locator('.main-area').getByText('画像の説明を入力してください').nth(1)).toBeVisible();
  });

  await test.step('後片付け: テスト用ページを削除する', async () => {
    await page.locator('.sidebar').locator('li').filter({ hasText: '無題のページ' }).last()
      .getByRole('button', { name: '設定' }).click();
    await page.locator('.page-config').getByRole('button', { name: 'ページ削除' }).click();
    await expect(page.locator('.confirm-dialog').getByText('このページを削除します')).toBeVisible();
    await page.locator('.confirm-dialog').getByRole('button', { name: 'OK' }).click();
  });
});

補正前のコードと比べると、主な変更点は次の3点です。

  • すべてのロケーターに、対象の要素を含むエリアのコンテナが付いた
  • 「ここに追加」のnth(1)がnth(0)に変わった
  • 操作が、準備・本題・後片付けの3つのステップにまとまった

「ここに追加」はヘッダーエリアとメインエリアの両方に表示されるため、記録時には2つめを示すnth(1)が付いていました。メインエリアで範囲を絞ると候補は1つになるので、同一性の検証で番号が0に直されています。

「ここに追加」のロケーターの変化
// 記録されたコード
await page.getByText('ここに追加').nth(1).click();

// 補正後のコード
await page.locator('.main-area').getByText('ここに追加').nth(0).click();

なお、ほかのロケーターで指定されたnth()は、範囲を絞っても数える順番が変わらず、元の番号のままです。

構造化では、画像部品の追加を確かめるexpect()は準備に入り、複製を確かめるexpect()までが本題になりました。ステップ名を追うだけで、このテストが何を確かめているのかがわかります。

コンテナにするクラスを一覧にしておく

コンテナにするクラスの選定では、集めたクラス名を見て、画面のエリアを表しているかをClaudeに判断させました。ただ、この判断はクラス名に頼っています。名前からエリアだと読み取りにくいクラスは、候補から外れることがあります。また、同じ画面でも、実行のたびに選ばれるクラスが変わることがあります。

そこで筆者のスキルでは、プロジェクトでコンテナとして使うクラスを一覧にしたファイル(container-classes.md)を用意し、コンテナを選ぶときに、まずこの一覧を参照させています。一覧に当てはまるクラスがあればそれを採用し、なければ先ほどの基準で選んだうえで、選んだクラスを一覧に追記します。一覧がまだない場合は、ソースコードを調べて一覧を作るようにしています。

container-classes.mdの例
| クラス名 | エリアの説明 |
| --- | --- |
| `.sidebar` | ページ一覧のサイドバー |
| `.main-area` | ページのメインエリア |
| `.settings-panel` | 部品の設定パネル |

一覧にあるクラスは、名前に関係なくコンテナとして使われるので、実行のたびに選び方がぶれるのを抑えられます。

まとめ

今回は、codegenで記録したコードを、Playwright MCPとClaude Codeで補正する方法を紹介しました。実際のページを確かめながらロケーターにコンテナを付け、test.step()で処理のまとまりを示すことで、記録したコードを壊さずに、保守しやすい形に整えられました。

このシリーズでは、Playwright MCPを使ったE2Eテストコードの生成を扱ってきました。言葉で指示できる画面ではClaudeに一から生成させ、言葉にしにくい画面ではcodegenで記録したコードをClaudeに補正させることで、保守性を考慮したテストコードを作ることができました。テストを作る画面に合わせて、この2つの方法を使い分けてみてください。

この記事の内容についての意見・感想を送る
CodeGridのDiscordで知識を深めよう。参加する。

人気のシリーズ