CucumberとScreenplay設計によるE2Eテスト
Web アプリケーション開発において、自動化テストは不可欠です。
特にリリース前の E2E テストの重要性は高いでしょう。
今回、 BDD で有名な Cucumber と Screenplay 設計を取り入れた経験を紹介します。
テスト設計のアプローチ
まず、E2E テスト設計には複数のアプローチが存在すると思います。
-
ユーザーストーリーに基づくテスト
- アジャイル開発で使用され、ユーザーの視点を反映した要件記述に基づいてテストを設計します。
-
ビジネス要件に基づくテスト
- ビジネスの目的や要求を直接反映したテストをデザインし、ビジネスの価値と目標に焦点を当てます。
-
シナリオテスト
- 実世界の業務プロセスやユーザーシナリオを模倣したテストケースを用いて、システムの振る舞いを評価します。
私はこれまで、Web 業界におけるアジャイル開発での開発経験が多いです。
プロダクトマネージャーやその他の非開発者ビジネスサイドのメンバーと協業する際、1 番目のユーザーストーリーを決めてテスト設計することが多いです。
そのため今回、1 番目のアプローチを想定します。
次に、ユーザーストーリーからテストケースを作成するのはどの段階でしょうか。
シフトレフトの考え方で、実装段階よりも前の段階で行う方が改修コストやリリース遅れなどのリスクを低減できます。
そこで、以下のようなアプローチを取ります。
-
受け入れテスト駆動開発(ATDD)
- 開発前に受け入れ基準を定義し、それを満たすテストケースを作成してから開発を進めるアプローチです。
開発前に達成したいユーザーストーリーと、それを満たすテストケースを作成します。ここの作業は、ビジネスサイドのメンバーと協業します。
テストケースのフォーマットは、Given-When-Then で表現する Gherkin 形式を採用します。
-
振る舞い駆動開発(BDD)
- 「Given-When-Then」形式の振る舞いシナリオを用いて、システムの振る舞いを定義し、それに基づいてテストケースを作成します。
テストツールとして、Gherkin を読み込みテストを実行できる Cucumber を使います。
例 Gherkin 形式のシナリオ
例えば、オンラインストアでの商品を見つけるユーザーストーリーに関して、以下のようなシナリオを想定します。
# online-store.feature
Feature: Online Store
Scenario: customer finds product by name
# - Apisitt, responsible for setting up test data using the REST API
# - Wendy, representing a customer interacting with the web UI
Given Apisitt sets up product catalogue with:
| name | price |
| Apples | £2.50 |
When Wendy looks for 'Apples'
Then she should see top search result of:
| name | Apples |
| price | £2.50 |
※ Screenplay Pattern - serenity-js.org より引用
このシナリオは、Gherkin 形式で書いています。
ファイル名は *.feature
となります。Markdown でも記述できます。
さらに、シナリオを日本語で書くこともできます。
その他、Gherkin の書き方やプラクティスついて、以下を参照してください。
次に、シナリオを満たすテストを書きましょう。以下が、シナリオを満たすテストコードです。
先のシナリオの Given、When、Then が、以下のテストコードに対応してます。(Actor は無視して良いです)
// online-store.steps.ts
import { Given, When, Then, DataTable } from "@cucumber/cucumber";
import { Actor } from "@serenity-js/core";
Given(
"{actor} sets up product catalogue with:",
(actor: Actor, products: DataTable) => actor.attemptsTo()
);
When("{actor} looks for {string}", (actor: Actor, productName: string) =>
actor.attemptsTo()
);
Then(
"{pronoun} should see top search result of",
(actor: Actor, expectedResult: string) => actor.attemptsTo()
);
テストを実行するには、以下のコマンドで実行できます。
npx cucumber-js
こちらのテストが成功すれば、online-store.feature の機能が担保していることが分かります。
つまり、受け入れ可能となり、リリース可能となります。
テスト実装
それでは、テストを実装しましょう。
テストの実装方法には、以下のようなものがあります。
-
ページオブジェクトモデル
- ウェブアプリケーションの各ページをオブジェクトとしてモデル化し、UI テストのメンテナンスと再利用性を向上させるデザインパターン。
-
キーワード駆動テスト
- キーワード(アクションや操作)を用いてテストスクリプトを記述し、非技術者でも理解しやすく、メンテナンスしやすいテストを実現する方法。
-
Screenplay
- 「アクター」と「タスク」に基づいてテストシナリオをモデル化し、テストの可読性と柔軟性を向上させるデザインパターン。
Screenplay は、ユーザーストーリーに基づくテストとの相性が良いと思い、今回試してみました。
ライブラリ選定
Screenplay 設計を試す場合、以下の候補がありました。
個人的な好みで、E2E テストは Playwright を使いたかったので、cucumber/screenplay.js を除外しました。
また、Screenplay という設計手法を取り入れるだけであれば、Tallyb/cucumber-playwright でも良かったのですが、以下の点で困ったので除外しました。
- Screenplay の五つの要素(後述します)を自身で実装する必要がある。
- 各シナリオごとに Actor を管理する必要が生じる。
そこで、Screenplay 設計に必要な要素が実装されている serenity-js/serenity-js を採用しました。
Screenplay とは
Screenplay とは、Screenplay Pattern - serenity-js.org より要約すると、以下のようなものです。
Screenplay パターンは、ビジネスの用語をテストシナリオに取り入れ、抽象化の層を効果的に使用することで、高品質な自動受け入れテストを書くためのユーザー中心のアプローチです。
このパターンは、アクターとその目標に焦点を当て、ドメイン言語を使用することで、技術者とビジネス関係者の間の協力と理解を促進します。
私が Screenplay パターンを良いなと思ったのは、以下の点です。
- ユーザー中心のアプローチ
- Actor というユーザーを中心に、テストを設計できる点
- 技術者とビジネス関係者の協力と理解を促進
- feature ファイルをビジネスサイドのメンバーと協業して作成できる点
- 抽象化の層がある
- タスク(後述します) を再利用することで、テストのメンテナンス性を向上できる
Screenplay における 5 つの要素
Screenplay には、以下の 5 つの要素が存在します。
https://serenity-js.org/handbook/design/screenplay-pattern/
5 つの要素について紹介します。
- Actor
- テスト対象のシステムとやりとりする人や外部システムを表します。
- 例
- ユーザー
- API
- Ability
- テスト対象のシステムとのインタラクションに必要な統合ライブラリを簡易に扱うためのものです。
- 例
- Web ページにアクセスする能力
- ブラウザ操作するためのライブラリ(Playwright など)をラップしたもの
- API リクエストを送信する能力
- API リクエストを送信するためのライブラリ(axios など)をラップしたもの
- Web ページにアクセスする能力
- Interaction
- アクターが特定のインターフェースを使用して行うことができる低レベルの活動を表します。
- 例
- ログインする
- ログインフォームにユーザー名とパスワードを入力し、ログインボタンをクリックする
- 商品をカートに追加する
- 商品ページにアクセスし、商品をカートに追加する
- ログインする
- Task
- ドメイン内のビジネスワークフローを意味のあるステップとしてモデル化するために使用されます。
- 例
- オンラインで商品を購入する
- ログインする
- 商品をカートに追加する
- 購入する
- オンラインで商品を購入する
- Question
- テスト対象のシステムやテスト実行環境から情報を取得するために使用されます。
- 例
- 現在のアカウント残高は?
- ユーザーのアカウント残高を取得する
- 現在のアカウント残高は?
また、serenity-js では、Note と呼ばれる Actor が情報を記憶できる要素もあります。
先ほどの online-store.steps.ts に、5 つの要素を実装した例を以下に紹介します。
// online-store.steps.ts
import { Given, When, Then, DataTable } from "@cucumber/cucumber";
import { Actor, Task } from "@serenity-js/core";
import { CallAnApi, PostRequest, Send, LastResponse } from "@serenity-js/rest";
import { BrowseTheWebWithPlaywright } from "@serenity-js/playwright";
import { Navigate, Page } from "@serenity-js/web";
import { Ensure, equals, endsWith } from "@serenity-js/assertions";
Given(
"{actor} sets up product catalogue with:",
(actor: Actor, products: DataTable) =>
actor.attemptsTo(setupProductCatalogue(products.hashes()))
);
When("{actor} looks for {string}", (actor: Actor, productName: string) =>
actor.attemptsTo(openOnlineStore(), findProductCalled(productName))
);
Then(
"{pronoun} should see top search result of",
(actor: Actor, expectedResult: string) =>
actor.attemptsTo(
// Question
Ensure.that(
topSearchResult().name,
equals(expectedResult.rowsHash().name)
),
// Question
Ensure.that(
topSearchResult().price,
equals(expectedResult.rowsHash().price)
)
)
);
// Task
const setupProductCatalogue = (products: Product[]) =>
Task.where(
`#actor sets up the product catalogue`,
// Interaction
Send.a(PostRequest.to("/products").with(products)),
// Question
Ensure.that(LastResponse.status(), equals(201))
);
// Task
const openOnlineStore = () =>
Task.where(
`#actor opens the online store`,
// Interaction
Navigate.to("https://example.org"),
// Question
Ensure.that(Page.current().title(), endsWith("My Example Shop"))
);
// Task
const findProductCalled = () =>
Task.where(`#actor looks for a product`, undefined); // コード例がなかったため、省略
また、{actor}
や{pronoun}
は、以下のように定義できます。
// parameter.steps.ts
import { defineParameterType } from "@cucumber/cucumber";
import { actorCalled, actorInTheSpotlight } from "@serenity-js/core";
import { CallAnApi } from "@serenity-js/rest";
import { BrowseTheWebWithPlaywright } from "@serenity-js/playwright";
defineParameterType({
regexp: /[A-Z][a-z]+/,
transformer(name: string) {
if (name === "Apisitt") {
return actorCalled(name).whoCan(
// Ability
CallAnApi.at("https://api.example.org")
);
}
if (name === "Wendy") {
return actorCalled(name).whoCan(
// Ability
BrowseTheWebWithPlaywright.using(browser)
);
}
},
name: "actor",
});
defineParameterType({
regexp: /he|she|they|his|her|their/,
transformer() {
return actorInTheSpotlight();
},
name: "pronoun",
});
※ parameter.steps.ts - serenity-js/serenity-js-cucumber-playwright-template
以上、Screenplay についての紹介でした。
シナリオのアンチパターン
せっかくなので、シナリオのアンチパターンも紹介します。
要約すると、以下のようなアンチパターンが存在します。
- コード後のフィーチャーファイルの記述
- ソフトウェア実装後に Gherkin のフィーチャーファイルを書くこと。開発推進ではなく、記録に過ぎない。
- ビジネス関係者によるシナリオの単独作成
- 製品オーナーやビジネスアナリストが単独でシナリオを作成すると、実際のビジネスニーズやテスト実行可能性を反映しない可能性がある。
- 開発者やテスターによるビジネス関係者との協議なしのシナリオ作成
- 開発者やテスターが単独でシナリオを作成すると、現実離れしたり非現実的なデータやユーザー記述になりがち。
- レベルが高すぎるシナリオ
- 高レベルで曖昧なシナリオは、具体的なビジネスルールを反映しておらず、信頼性が低い。
- 生きていないドキュメント
- 不十分な Gherkin は、システムの機能を正確に伝えるドキュメントとして機能しない。
- 不要な詳細による誤解
- シナリオに不要な詳細が含まれると、テストしたいビジネスルールの本質が曖昧になる。
- 不適切なシナリオ名
- シナリオの名前は内容を端的に示すべきだが、不適切な名前は内容の理解を妨げる。
- 初心者の間違い
- UI の詳細に過度に焦点を当てたり、個人的な代名詞「I」を使用するなど、初心者が犯しやすい間違い。
- Given/When/Then の不明確な区分
- Given、When、Then の区別が不明確な場合、シナリオの意図が不明瞭になる。
終わりに
今回、Screenplay 設計を取り入れた経験を紹介しました。
ぜひ、参考にしてみてください。
Discussion