見出し画像

CucumberとPlaywright+生成AIを使ってTypeScriptで自動テストを書こう!WebアプリやAndroidネイティブアプリもBDDで!


はじめに

SHIFTで自動化アーキテクトをやっている片山 嘉誉です。

最近「Behavior Driven Development (BDD) : 振る舞い駆動開発」によるテスト環境の構築に関して色々と試しているのですが、今回はCucumber(キューカンバー)Playwrightの組み合わせについて話をして行きたいと思います。

今まで試した組み合わせに関しては以下のようになっており、関連記事もあるので参照してみてください。

【開発言語:Python 組み合わせ:pytest-bdd × Playwright】

【開発言語:JavaScript 組み合わせ:CodeceptJS × Playwright】

【開発言語:TypeScript 組み合わせ:Cucumber × Playwright】

この記事で紹介

上記すべてに共通する話ですが、これらはGherkinと呼ばれる日本語で記載可能なテストシナリオにPlaywrightなどのテストスクリプトを紐づけて実行するという仕組みに成っています。

それぞれ使用している開発言語や実装方法に差異がありますが、Gherkinの書き方自体は変わらないのでテストシナリオを作る側とすればそれぞれの環境の差異は全くありません。

また、Gherkinはデータドリブンな書き方もできる為、どのようなデータの組み合わせでテストが行われているのか分かりやすいです。

その反面、テストを実行するためのコードをシナリオと紐づけて管理する必要があり、スクリプトを実装する側からするとその分コストが増えてしまいます。

恐らく大変なのは最初にテスト環境を作るところで、そんな面倒な環境構築を生成AIでやってしまおう!というのが私のトレンドになっています。

さて、では実際にCucumberとPlaywrightのテスト環境を作っていきたいと思います。

テスト環境構築について

CucumberPlaywrightをインストールしただけだとTypeScriptのimport文でエラーが発生してしまうため、ts-nodeも併せてインストールして設定していきます。

また、Playwrightをインストールする際にオプションの設問がいくつかありますが、すべてデフォルトで問題ありません。

■インストールコマンド

npm install --save-dev @cucumber/cucumber ts-node
npm init playwright@latest

Getting started with writing end-to-end tests with Playwright:
Initializing project in '.'
√ Do you want to use TypeScript or JavaScript? · TypeScript
√ Where to put your end-to-end tests? · tests
√ Add a GitHub Actions workflow? (y/N) · false
√ Install Playwright browsers (can be done manually via 'npx playwright install')? (Y/n) · true

Cucumberで使用するフォルダは自動生成されないため、手動で作成して行きます。

■併せて必要なフォルダを作成

mkdir features
mkdir pages
mkdir step_definitions

必要なフォルダができたら配下にテストで必要なファイルを作成して行くのですが、今回は以下のような構成で説明を進めて行きたいと思います。

※個々のファイルはこの後の説明で作成して行きます。

■プロジェクト構成

project
├─ cucumber.js … 設定ファイル
├─ features
│  └─ login.feature … Gherkinで記載するテストシナリオのベースファイル
├─ node_modules
├─ pages
│  └─ loginPage.ts … PageObjectのファイル
├─ step_definitions
│  └─ login.ts … Gherkinから呼び出される処理はこちらに記載する
└─ tests

PageObjectの部分は必須では無く、未経験者の方だとこの内容まで盛り込むと分かりづらくなるかとも思ったのですが、一応こういう事もできるよ、という意味でまとめて説明したいと思います。

フォルダの作成ができたらルートフォルダ直下に「cucumber.js」ファイルを作成し、必要な設定を行っていきます。

どのような設定ができるかはこちらを参照して貰えればと思いますが、今回使用する設定を以下に記載しておきます。

■cucumber.js(設定ファイル)

module.exports = {
    default: {
        require: ['step_definitions/**/*.ts'],  // テストスクリプトが格納される場所
        requireModule: ['ts-node/register'],    // TypeScript実行用の設定
        format: [
            'summary',
            'progress-bar',                     // 実行時にプログレスバーをログ表示する設定
            'html:cucumber-report.html'         // テスト結果をHTMLファイルで出力する設定
        ],
    }
}

以上でテスト環境の構築は完了です。

生成AIでコードを作って試しにテストを実行してみる

Playwrightのレコーディング機能について

では、ここからテストを実行するためのコードを生成AIで作っていこうと思うのですが、今回はTypeScriptの環境ということでVisual Studio CodeのGUIの操作で実行できるレコーディング機能を使ってコードを作って行きます。

まずはVisual Studio Codeの拡張機能から「Playwright Test for VSCode」をインストールしてください。

すると画面左側のフラスコのアイコンからブラウザで行った操作を保存する機能を呼び出すボタンが出現します。

このボタンを押すとブラウザが立ち上がり、以降の操作がコードとして記録されるのですが、今回はテストサイトとしてログインなどの動作を確認することができる「HOTEL PLANISPHERE」を使わせてもらうことにします。

このサイトはテスト自動化の学習用の練習サイトとなっており、ログイン用に以下の4つのアカウントが用意されています。

今回はこの4つのアカウントそれぞれでログインし、表示される名前と会員ランクが意図通りであるかを確認するテストを行いたいと思いますが、取り敢えずシナリオとしてはログインを行う操作ができれば良いので一番上に記載されているアカウントでログインを行う操作を記録します。

生成AIによるコードの生成について

では、GUIの操作で記録したコードに名前と会員ランクを確認するアノテーションを付け、生成AIでテストコードを作っていきます。

補足ですが、SHIFTでは社内で誰でもGPT-4が使える環境が提供されており、今回はその環境を使ってコードの生成を行っています。

もしこの記事を読んでやってみたけど全然ダメじゃん!っと思った方はGPT-4を使ってみてください。

■プロンプト

```
import { test, expect } from '@playwright/test';

test('test', async ({ page }) => {
  await page.goto('https://hotel.testplanisphere.dev/ja/');
  await page.getByRole('button', { name: 'ログイン' }).click();
  await page.getByLabel('メールアドレス').fill('ichiro@example.com');
  await page.getByLabel('パスワード').fill('password');
  await page.locator('#login-button').click();
  await expect(page.locator('#username')).toHaveText('山田一郎');
  await expect(page.locator('#rank')).toHaveText('プレミアム会員');
});
```
上記はTypeScriptで書かれたPlaywrightのコードである。
Cucumber.jsとPlaywrightを使ったTypeScriptのコードに変換して欲しい。
・featuresフォルダ配下にGherkinを作成して従い振る舞いを必ず日本語で定義する。
・featureファイルのフォーマットを以下に示す。
```
Feature: 目的

  Scenario Outline: シナリオ
    Given 前提条件"<定義1>"前提条件
    When 操作"<定義2>"操作
    Then 結果"<定義3>"結果

    Examples:
    | 定義1 | 定義2 | 定義3 |
    | A | B | C |
```
・振る舞いの中でテストシナリオとして変更可能な部分のみに鍵カッコを付け、テストシナリオとして変更できない部分に鍵カッコは付けない。
・step_definitionsフォルダ配下に振る舞いに対応するコードをTypeScriptで実装する。
 ・featuresファイルからパラメータを受け取る際は「{string}」のようにダブルクォーテーションを行わない。
 ・実装は元のコードを必ず流用する。
 ・「@cucumber/cucumber」をインポートする。
 ・exact()を使用する場合は「@playwright/test」をインポートする。
・pagesフォルダ配下にPageObjectを作成してコードを整理する。
・ブラウザのセッション情報は引き継げるようにする。
・ブラウザの設定にsync_playwrightのchromium.launch(headless=False)を指定する。
・テスト終了時はブラウザを終了させる。

残念ながら上記のコードは色々と問題があってそのまま実行できなかったのですが、素材として良さそうだったのでこれをベースにしたいと思います。

生成AIが作ったコードを基に私の方で手を加えたものを用意したので、それぞれファイルを作成して内容をコピペしてください。

■features\login.feature(テストシナリオ)

生成AIが出したコードだとたくさん""が付いていましたが、変更可能な部分だけ""を付けるよう改変しています。

また、Examplesに4つのアカウントと確認内容を追加しています。

Feature: ユーザーログイン

  Scenario Outline: ユーザーがログインできることを確認する
    Given "<url>" へアクセスする
    When ログインボタンをクリックする
    And "メールアドレス""<email>" を入力する
    And "パスワード""<password>" を入力する
    And ログイン実行ボタンをクリックする
    Then ユーザーネームが "<username>" であること
    And ランクが "<rank>" であること

    Examples:
    | url                                   | email               | password  | username   | rank           |
    | https://hotel.testplanisphere.dev/ja/ | ichiro@example.com  | password  | 山田一郎   | プレミアム会員 |
    | https://hotel.testplanisphere.dev/ja/ | sakura@example.com  | pass1234  | 松本さくら | 一般会員       |
    | https://hotel.testplanisphere.dev/ja/ | jun@example.com     | pa55w0rd! | 林潤       | プレミアム会員 |
    | https://hotel.testplanisphere.dev/ja/ | yoshiki@example.com | pass-pass | 木村良樹   | 一般会員       |

■step_definitions\login.ts(テストスクリプト)

login.featureファイルの修正と合わせてGiven/When/Thenの定義部分を修正しています。(ここを合わせないと実行できないため)

補足ですが、Cucumberの場合はGherkin側に""が付いていても受け取る側では""が不要です。(ここがpytest-bddの場合は""を付けないと行けなかったりするので、ツールによる微妙な差異があります)

import { chromium, Page } from '@playwright/test';
import { Given, When, Then, Before, After } from '@cucumber/cucumber';
import { LoginPage } from '../pages/loginPage';

let page: Page;
let loginPage: LoginPage;

Before(async () => {
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext();
  page = await context.newPage();
  loginPage = new LoginPage(page);
});

After(async () => {
  await page.close();
});

Given('{string} へアクセスする', async (url: string) => {
  await page.goto(url);
});

When('ログインボタンをクリックする', async () => {
  await loginPage.clickLoginButton();
});

When('{string} に {string} を入力する', async (field: string, value: string) => {
  await loginPage.fillField(field, value);
});

When('ログイン実行ボタンをクリックする', async () => {
  await loginPage.clickLoginExecuteButton();
});

Then('ユーザーネームが {string} であること', async (username: string) => {
  await loginPage.verifyUsername(username);
});

Then('ランクが {string} であること', async (rank: string) => {
  await loginPage.verifyRank(rank);
});

■pages\loginPage.ts(ページオブジェクト)

expectのインポートが抜けていたので追加しました。(なぜか何度生成しても自動で付与してくれない…)

import { Page, expect } from '@playwright/test';

export class LoginPage {
  private page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  async clickLoginButton() {
    await this.page.getByRole('button', { name: 'ログイン' }).click();
  }

  async fillField(field: string, value: string) {
    await this.page.getByLabel(field).fill(value);
  }

  async clickLoginExecuteButton() {
    await this.page.click('#login-button');
  }

  async verifyUsername(username: string) {
    await expect(this.page.locator('#username')).toHaveText(username);
  }

  async verifyRank(rank: string) {
    await expect(this.page.locator('#rank')).toHaveText(rank);
  }
}

テストの実行について

この3つのファイルの関係性ですが、処理の流れとしては以下の様になります。

features\login.feature(テストシナリオ)
 ↓
step_definitions\login.ts(テストスクリプト)
 ↓
pages\loginPage.ts(ページオブジェクト)

例えば具体的な処理を抜粋すると、以下の様になります。

【features\login.feature】
    And "メールアドレス""" を入力する

 ↓
【step_definitions\login.ts】
When('{string} に {string} を入力する', async (field: string, value: string) => {
  await loginPage.fillField(field, value);
});

 ↓
【pages\loginPage.ts】
  async fillField(field: string, value: string) {
    await this.page.getByLabel(field).fill(value);
  }

この処理を実行する(テストを実行する)コマンドは「npx cucumber-js」になるのですが、テストが終わったら自動で終了するようオプションに「--exit」を付与して「npx cucumber-js --exit」と実行するのが良いです。

また、「npx cucumber-js --exit .\features\login.feature」のようにファイル名を指定すれば、指定したファイルのテストだけ実行することができます。

■実行結果

PS C:\work\cucumber> npx cucumber-js --exit
4 scenarios (4 passed)
28 steps (28 passed)
0m11.708s (executing steps: 0m11.670s)
PS C:\work\cucumber>

この際「cucumber.js」の設定がうまく行っていれば、ルートフォルダ直下に「cucumber-report.html」というファイルが生成されます。

中を見ると以下の様にテストの実行結果が分かるようになっています。

また、途中でテストが失敗した場合は以下の様にシナリオのどこで失敗したかがログで確認することができます。

Gherkinの日本語化について

CucumberではGherkinの表記について多言語対応されており、(見やすいかどうかは別として)すべて日本語でシナリオを書くことができます。

やり方は簡単で、上記の対応表に従ってGherkinを書き直した上で、ファイルの先頭に「# language: ja」を挿入するだけです。

先ほどのシナリオを日本語で書きなおすと以下の様になります。

# language: ja

機能: ユーザーログイン

  テンプレ: ユーザーがログインできることを確認する
    前提 "" へアクセスする
    もし ログインボタンをクリックする
    かつ "メールアドレス""" を入力する
    かつ "パスワード""" を入力する
    かつ ログイン実行ボタンをクリックする
    ならば ユーザーネームが "" であること
    かつ ランクが "" であること

    例:
    | url                                   | email               | password  | username   | rank           |
    | https://hotel.testplanisphere.dev/ja/ | ichiro@example.com  | password  | 山田一郎   | プレミアム会員 |
    | https://hotel.testplanisphere.dev/ja/ | sakura@example.com  | pass1234  | 松本さくら | 一般会員       |
    | https://hotel.testplanisphere.dev/ja/ | jun@example.com     | pa55w0rd! | 林潤       | プレミアム会員 |
    | https://hotel.testplanisphere.dev/ja/ | yoshiki@example.com | pass-pass | 木村良樹   | 一般会員       |

実行してみると「cucumber-report.html」の方も日本語化されていることが分かります。

生成AIでも上記フォーマットに従い日本語でGherkinを出力させることは出来るのですが、デフォルトの英語の方が学習量が多そうですし出力も安定しているので、生成AIを使うのであれば英語の方が良いように思います。

AndroidネイティブアプリのテストもBDDで実行できるようにしてみる

ここからは実験的な試みですが、以前Playwrightで作成したAndroidネイティブアプリのテストコードを生成AIでGherkinから実行できるよう改変してみたいと思います。

以下のコードはAndroidのエミュレータにプリセットされている時計アプリを起動し、5秒のタイマーを設定して鳴動したら停止させるというものになります。

今回はこれをWebアプリで使用したプロンプトを流用して3つのファイルに分割してみたいと思います。

■プロンプト(前回からブラウザに関する部分を削除したもの)

```
// android.spec.ts
import { test, _android, expect } from '@playwright/test';

async function takeScreenshot(device) {
  const date = new Date();
  const formattedDate = `${date.getFullYear()}${('0' + (date.getMonth() + 1)).slice(-2)}${('0' + date.getDate()).slice(-2)}-${('0' + date.getHours()).slice(-2)}-${('0' + date.getMinutes()).slice(-2)}-${('0' + date.getSeconds()).slice(-2)}-${('00' + date.getMilliseconds()).slice(-3)}`;

  await device.screenshot({ path: `${formattedDate}.png` });
}

test('タイマーを5秒でセットし、発火したら止める', async () => {
  const [device] = await _android.devices();
  console.log(`Model: ${device.model()}`);
  console.log(`Serial: ${device.serial()}`);

  // タイマーを停止させる処理がなかなか動かず30秒を超えるため延長
  test.setTimeout(60000);

  {
    // 参考のため実行時間を計測
    const startTime = Date.now(); // テスト開始時刻を取得

    // 時計アプリを起動する
    await device.shell('am force-stop com.google.android.deskclock');
    await device.shell('am start com.google.android.deskclock/com.android.deskclock.DeskClock');

    // タイマータブに切り替える
    await device.tap({
      text: 'Timer'
    },);

    takeScreenshot(device);

    // 前回の状態によってはタイマーを削除する
    try {
      await device.tap({
        res: 'com.google.android.deskclock:id/left_button',
        desc: 'Delete'
        },);
    } catch (error) {
      // 失敗する場合は初期画面からの起動なので問題なし
    }

    // タイマーに設定されている時間を取得する
    const timer_setup_time_promise = await device.info({
      res: 'com.google.android.deskclock:id/timer_setup_time'
    });
    // 初期値であることを確認する
    expect(timer_setup_time_promise.text).toContain('00h 00m 00s');

    takeScreenshot(device);

    // タイマーを5秒に設定する
    await device.tap({
      res: 'com.google.android.deskclock:id/timer_setup_digit_5'
    },);

    takeScreenshot(device);

    // スタートボタンをタップする
    await device.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Start'
    },);

    // 3秒間待機
    await new Promise(resolve => setTimeout(resolve, 3000));

    takeScreenshot(device);

    // 3秒間待機
    await new Promise(resolve => setTimeout(resolve, 3000));

    takeScreenshot(device);

    // アラーム鳴動までの時間を計測
    const endTime = Date.now();
    const elapsedSec = (endTime - startTime) / 1000;
  
    // 経過時間をログに出力
    console.log(`Elapsed time: ${elapsedSec} sec`);

    // ストップボタンをタップする
    await device.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Stop'
    },);

    takeScreenshot(device);

    const timer_time_text_promise = await device.info({
      res: 'com.google.android.deskclock:id/timer_time_text'
    });
    expect(timer_time_text_promise.text).toContain('5');
  }

  await device.close();
});
```
上記はTypeScriptで書かれたPlaywrightのコードである。
Cucumber.jsとPlaywrightを使ったTypeScriptのコードに変換して欲しい。
・featuresフォルダ配下にGherkinを作成して従い振る舞いを必ず日本語で定義する。
・featureファイルのフォーマットを以下に示す。
```
Feature: 目的

  Scenario Outline: シナリオ
    Given 前提条件"<定義1>"前提条件
    When 操作"<定義2>"操作
    Then 結果"<定義3>"結果

    Examples:
    | 定義1 | 定義2 | 定義3 |
    | A | B | C |
```
・振る舞いの中でテストシナリオとして変更可能な部分のみに鍵カッコを付け、テストシナリオとして変更できない部分に鍵カッコは付けない。
・step_definitionsフォルダ配下に振る舞いに対応するコードをTypeScriptで実装する。
 ・featuresファイルからパラメータを受け取る際は「{string}」のようにダブルクォーテーションを行わない。
 ・実装は元のコードを必ず流用する。
 ・「@cucumber/cucumber」をインポートする。
 ・exact()を使用する場合は「@playwright/test」をインポートする。
・pagesフォルダ配下にPageObjectを作成してコードを整理する。

概ねうまくコードが生成されたのですが、一部実行時に少しエラーが出たので、流れで生成AIにコードを修正して貰いました。(その他、若干元の処理から漏れていた部分を手動で修正)

ファイルの命名規則に統一感が無いのでこれもプロンプトで指定するか手動で直した方が良さそうですが、ひとまず生成AIが提案してきた通りにファイルを配置して実行してみます。

■features\timer.feature(テストシナリオ)

Examplesに「8」を追加しました。

# timer.feature
Feature: タイマー機能のテスト

  Scenario Outline: タイマーを設定し、鳴動したら止める
    Given 時計アプリを起動する
    When タイマーを""秒に設定する
    Then タイマーが設定した時間""秒後に鳴る

    Examples:
    | time |
    | 5 |
    | 8 |

■step_definitions\timer.steps.ts(テストスクリプト)

このテストは時間が掛かるものなので、「setDefaultTimeout(60 * 1000);」を追記してタイムアウト時間を延ばすようにしました。

また「"{string}"」では動作しないため、""を外して「{string}」に修正しています。

// timer.steps.ts
import { Given, When, Then, setDefaultTimeout } from '@cucumber/cucumber';
import { TimerPage } from '../pages/timer.page';

setDefaultTimeout(60 * 1000);

const timerPage = new TimerPage();

Given('時計アプリを起動する', async () => {
  await timerPage.openClockApp();
});

When('タイマーを{string}秒に設定する', async (time: string) => {
  await timerPage.setTimer(time);
});

Then('タイマーが設定した時間{string}秒後に鳴る', async (time: string) => {
  await timerPage.checkTimer(time);
});

■pages\timer.page.ts(ページオブジェクト)

このファイルで実行時にエラーが出たので生成AIに改修して貰いました。

// timer.page.ts
import { _android, expect, AndroidDevice } from '@playwright/test';

export class TimerPage {
  private device: AndroidDevice | undefined;  // Declare device as possibly undefined

  async openClockApp() {
    [this.device] = await _android.devices();
    await this.device!.shell('am force-stop com.google.android.deskclock');
    await this.device!.shell('am start com.google.android.deskclock/com.android.deskclock.DeskClock');
  }

  async setTimer(time: string) {
    await this.device!.tap({
      text: 'Timer'
    });
    // 前回の状態によってはタイマーを削除する
    try {
        await this.device!.tap({
            res: 'com.google.android.deskclock:id/left_button',
            desc: 'Delete'
        },);
    } catch (error) {
        // 失敗する場合は初期画面からの起動なので問題なし
    }

    await this.device!.tap({
      res: `com.google.android.deskclock:id/timer_setup_digit_${time}`
    });
    await this.device!.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Start'
    });
  }

  async checkTimer(time: string) {
    // Check if the timer has gone off after the specified time
    await new Promise(resolve => setTimeout(resolve, parseInt(time, 10) * 1000));
    await this.device!.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Stop'
    });
    const timer_time_text_promise = await this.device!.info({
      res: 'com.google.android.deskclock:id/timer_time_text'
    });
    expect(timer_time_text_promise.text).toContain(time);
  }
}

あまり詳しい説明をすると長くなるので割愛しますが、「com.google.android.deskclock:id/timer_setup_digit_${time}」の部分はタイマーのボタンに紐づく実装になるよう生成AIが作ってくれたので、これをそのまま使わせて貰いました。(10秒を超えると入力できないので、そこは実装側で工夫が必要ですが)

テストを実行すると期待通り5秒のタイマーと8秒のタイマーをセットし、テストが問題無く実行されることを確認しました。

■実行結果

PS C:\work\cucumber> npx cucumber-js --exit .\features\timer.feature
2 scenarios (2 passed)
6 steps (6 passed)
1m39.364s (executing steps: 1m39.342s)
PS C:\work\cucumber>

おわりに

Cucumberは古くからBDDを行うためのフレームワークとして使われているので、幅広い言語やプラットフォームに対応しており、とても有用なツールであると思います。

今回はPlaywrightを使ったE2Eテストという視点でCucumberを使ってみましたが、他にも色々活用方法はあると思うので、ご自身が行っている作業と照らし合わせて見てみると面白いかも知れません。

BDDは分かりやすい反面実装が面倒という事もありなかなか流行らないという印象ですが、生成AIにより実装が楽にできるようになればシナリオを作る人と実装をする人で分業できるため、効率も上がるのでは無いかと考えています。

コストの問題で導入が先送りされがちな自動テストですが、コストが下がれば導入したいと思っている人はたくさん居るでしょうし、生成AIを使ってもっと自動化が流行れば良いな、っと思っています。

それではまた次の記事でお会いしましょう!「スキ」と「フォロー」もお願いします!


執筆者プロフィール:片山 嘉誉 (Katayama Yoshinori)
SIerとして長年AOSP(Android Open Source Project)をベースとしたAndroidスマートフォンの開発に携わり、併せてスマートフォン向けのアプリ開発を行ってきた。 Wi-FiやBluetoothといった無線通信機能の担当が長かったため通信系のシステムに強く、AWSを使用したシステム開発の経験もある。 生産性向上や自動化というワードが大好きで、SHIFTでは自動化アーキテクトとして参画。 最近のマイブームはChatGPTとStable Diffusionである。

《お問合せはお気軽に》

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら