ホーム自己紹介ブログ
NO.106
DATE2023. 11. 13

CucumberとScreenplay設計によるE2Eテスト

Web アプリケーション開発において、自動化テストは不可欠です。 特にリリース前の E2E テストの重要性は高いでしょう。 今回、 BDD で有名な Cucumber と Screenplay 設計を取り入れた経験を紹介します。

テスト設計のアプローチ

まず、E2E テスト設計には複数のアプローチが存在すると思います。

  • ユーザーストーリーに基づくテスト
    • アジャイル開発で使用され、ユーザーの視点を反映した要件記述に基づいてテストを設計します。
  • ビジネス要件に基づくテスト
    • ビジネスの目的や要求を直接反映したテストをデザインし、ビジネスの価値と目標に焦点を当てます。
  • シナリオテスト
    • 実世界の業務プロセスやユーザーシナリオを模倣したテストケースを用いて、システムの振る舞いを評価します。

私はこれまで、Web 業界におけるアジャイル開発での開発経験が多いです。 プロダクトマネージャーやその他の非開発者ビジネスサイドのメンバーと協業する際、1 番目のユーザーストーリーを決めてテスト設計することが多いです。 そのため今回、1 番目のアプローチを想定します。

次に、ユーザーストーリーからテストケースを作成するのはどの段階でしょうか。 シフトレフトの考え方で、実装段階よりも前の段階で行う方が改修コストやリリース遅れなどのリスクを低減できます。 そこで、以下のようなアプローチを取ります。

  • 受け入れテスト駆動開発(ATDD)
    • 開発前に受け入れ基準を定義し、それを満たすテストケースを作成してから開発を進めるアプローチです。

開発前に達成したいユーザーストーリーと、それを満たすテストケースを作成します。ここの作業は、ビジネスサイドのメンバーと協業します。 テストケースのフォーマットは、Given-When-Then で表現する Gherkin 形式を採用します。

  • 振る舞い駆動開発(BDD)
    • 「Given-When-Then」形式の振る舞いシナリオを用いて、システムの振る舞いを定義し、それに基づいてテストケースを作成します。

テストツールとして、Gherkin を読み込みテストを実行できる Cucumber を使います。

cucumber.io

例 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/MARKDOWN_WITH_GHERKIN.md at main · cucumber/gherkin
A parser and compiler for the Gherkin language. Contribute to cucumber/gherkin development by creating an account on GitHub.
github.com

さらに、シナリオを日本語で書くこともできます。

cucumber.io

その他、Gherkin の書き方やプラクティスついて、以下を参照してください。

cucumber.io

次に、シナリオを満たすテストを書きましょう。以下が、シナリオを満たすテストコードです。 先のシナリオの 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 設計を試す場合、以下の候補がありました。

  • Tallyb/cucumber-playwright
  • cucumber/screenplay.js
  • serenity-js/serenity-js

個人的な好みで、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 つの要素が存在します。

Five elements of the Screenplay Pattern - https://serenity-js.org
Five elements of the Screenplay Pattern - https://serenity-js.org

5 つの要素について紹介します。

  • Actor
    • テスト対象のシステムとやりとりする人や外部システムを表します。
    • 例
      • ユーザー
      • API
  • Ability
    • テスト対象のシステムとのインタラクションに必要な統合ライブラリを簡易に扱うためのものです。
    • 例
      • Web ページにアクセスする能力
        • ブラウザ操作するためのライブラリ(Playwright など)をラップしたもの
      • API リクエストを送信する能力
        • API リクエストを送信するためのライブラリ(axios など)をラップしたもの
  • Interaction
    • アクターが特定のインターフェースを使用して行うことができる低レベルの活動を表します。
    • 例
      • ログインする
        • ログインフォームにユーザー名とパスワードを入力し、ログインボタンをクリックする
      • 商品をカートに追加する
        • 商品ページにアクセスし、商品をカートに追加する
  • Task
    • ドメイン内のビジネスワークフローを意味のあるステップとしてモデル化するために使用されます。
    • 例
      • オンラインで商品を購入する
        • ログインする
        • 商品をカートに追加する
        • 購入する
  • Question
    • テスト対象のシステムやテスト実行環境から情報を取得するために使用されます。
    • 例
      • 現在のアカウント残高は?
        • ユーザーのアカウント残高を取得する

また、serenity-js では、Note と呼ばれる Actor が情報を記憶できる要素もあります。

TakeNotes | API | Serenity/JS
An [`Ability`](/api/core/class/Ability/) that enables an [`Actor`](/api/core/class/Actor/) to remember information to be recalled during a test scenario. Under the hood, [`TakeNotes`](/api/core/class/TakeNotes/) uses a [`Notepad`](/api/core/class/Notepad/), which state can be populated both during initialisation or while the test scenario is executed. Populating the notepad when it's initialised can be useful to associate authentication credentials or personal details with a given actor, while dynamic recording of notes during a test scenario can be useful when the data to be recorded is not known upfront - for example when we want the actor to remember a JWT stored in the browser and then use it when sending API requests. **Pro tip:** [`TakeNotes`](/api/core/class/TakeNotes/), [`Notepad`](/api/core/class/Notepad/) and [`notes`](/api/core/function/notes/) can be typed using [TypeScript generics](https://www.typescriptlang.org/docs/handbook/2/generics.html) to help you avoid typos when specifying note names. See [notes](/api/core/function/notes) and [`Notepad`](/api/core/class/Notepad/) for more usage examples. ## Remembering and retrieving a value ```ts import { actorCalled, Log, notes, TakeNotes } from '@serenity-js/core' await actorCalled('Leonard') .whoCan(TakeNotes.usingAnEmptyNotepad()) .attemptsTo( notes().set('my_note', 'some value'), Log.the(notes().get('my_note')), // emits 'some value' ) ``` ## Using generics ```ts import { actorCalled, Log, notes, TakeNotes } from '@serenity-js/core' interface MyNotes { personalDetails: { firstName: string; lastName: string; } } await actorCalled('Leonard') .whoCan(TakeNotes.usingAnEmptyNotepad()) .attemptsTo( Log.the(notes().has('personalDetails')), // emits false Log.the(notes().get('personalDetails').isPresent()), // emits false notes().set('personalDetails', { firstName: 'Leonard', lastName: 'McLaud' }), Log.the(notes().has('personalDetails')), // emits true Log.the(notes().get('personalDetails').isPresent()), // emits true Log.the(notes().get('personalDetails').firstName), // emits 'Leonard' Log.the(notes().get('personalDetails').firstName.toLocaleUpperCase()), // emits 'LEONARD' ) ``` ## Populating the notepad with initial state ```ts import { actorCalled, Log, Note, Notepad, TakeNotes } from '@serenity-js/core' interface MyNotes { firstName: string; lastName: string; } await actorCalled('Leonard') .whoCan( TakeNotes.using(Notepad.with({ firstName: 'Leonard', lastName: 'McLaud', })) ) .attemptsTo( notes().set('lastName', 'Shelby'), Log.the(notes().get('firstName')), // emits 'Leonard' Log.the(notes().get('lastName')), // emits 'Shelby' ) ``` ## Recording a dynamic note ```ts import { actorCalled, Log, Notepad, notes, TakeNotes } from '@serenity-js/core' import { By, Text, PageElement } from '@serenity-js/web' import { BrowseTheWebWithWebdriverIO } from '@serenity-js/webdriverio' interface OnlineShoppingNotes { promoCode: string; } const promoCodeBanner = () => PageElement.located(By.css('[data-testid="promo-code"]')) .describedAs('promo code'); const promoCodeInput = () => PageElement.located(By.css('[data-testid="promo-code-input"]')) .describedAs('promo code field'); await actorCalled('Leonard') .whoCan( BrowseTheWebWithWebdriverIO.using(browser), TakeNotes.using(Notepad.empty()) ) .attemptsTo( notes().set('promoCode', Text.of(promoCode()), // ... Enter.theValue(notes().get('promoCode')) .into(promoCodeInput()) ) ``` ## Clearing a notepad before each test scenario (Mocha) ```ts import 'mocha'; beforeEach(() => actorCalled('Leonard') .attemptsTo( notes().clear(), )) ``` ## Clearing a notepad before each test scenario (Cucumber) ```ts import { Before } from '@cucumber/cucumber' Before(() => actorCalled('Leonard') .attemptsTo( notes().clear(), )); ``` ## Importing notes from an API response ```ts // given an example API: // GET /generate-test-user // which returns: // { "first_name": "Leonard", "last_name": "Shelby" } import { actorCalled, Log, Notepad, notes, TakeNotes } from '@serenity-js/core' import { CallAnApi, GetRequest, Send } from '@serenity-js/rest' interface PersonalDetails { first_name: string; last_name: string; } interface MyNotes { personalDetails?: PersonalDetails; } await actorCalled('Leonard') .whoCan( CallAnApi.at('https://api.example.org/'), TakeNotes.using(Notepad.empty()) ) .attemptsTo( Send.a(GetRequest.to('/generate-test-user')), notes().set('personalDetails', LastResponse.body()), Log.the(notes().get('personalDetails').first_name), // emits 'Leonard' Log.the(notes().get('personalDetails').last_name), // emits 'Shelby' ) ``` ## Using the QuestionAdapter ```ts import { actorCalled, Log, Notepad, notes, TakeNotes } from '@serenity-js/core' interface AuthCredentials { username?: string; password?: string; } interface MyNotes { auth: AuthCredentials; } await actorCalled('Leonard') .whoCan( TakeNotes.using( Notepad.with({ // typed initial state auth: { username: 'leonard@example.org', password: 'SuperSecretP@ssword!', } }) ) ) .attemptsTo( Log.the( notes() // typed notes .get('auth') // returns QuestionAdapter .password // returns QuestionAdapter .charAt(0) .toLocaleLowerCase(), // emits "s" ), ) ``` ## Learn more - [notes](/api/core/function/notes) - [`Notepad`](/api/core/class/Notepad/)
serenity-js.org

先ほどの 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 についての紹介でした。

シナリオのアンチパターン

せっかくなので、シナリオのアンチパターンも紹介します。

Cucumber Anti-Patterns
www.thinkcode.se

要約すると、以下のようなアンチパターンが存在します。

  • コード後のフィーチャーファイルの記述
    • ソフトウェア実装後に Gherkin のフィーチャーファイルを書くこと。開発推進ではなく、記録に過ぎない。
  • ビジネス関係者によるシナリオの単独作成
    • 製品オーナーやビジネスアナリストが単独でシナリオを作成すると、実際のビジネスニーズやテスト実行可能性を反映しない可能性がある。
  • 開発者やテスターによるビジネス関係者との協議なしのシナリオ作成
    • 開発者やテスターが単独でシナリオを作成すると、現実離れしたり非現実的なデータやユーザー記述になりがち。
  • レベルが高すぎるシナリオ
    • 高レベルで曖昧なシナリオは、具体的なビジネスルールを反映しておらず、信頼性が低い。
  • 生きていないドキュメント
    • 不十分な Gherkin は、システムの機能を正確に伝えるドキュメントとして機能しない。
  • 不要な詳細による誤解
    • シナリオに不要な詳細が含まれると、テストしたいビジネスルールの本質が曖昧になる。
  • 不適切なシナリオ名
    • シナリオの名前は内容を端的に示すべきだが、不適切な名前は内容の理解を妨げる。
  • 初心者の間違い
    • UI の詳細に過度に焦点を当てたり、個人的な代名詞「I」を使用するなど、初心者が犯しやすい間違い。
  • Given/When/Then の不明確な区分
    • Given、When、Then の区別が不明確な場合、シナリオの意図が不明瞭になる。

終わりに

今回、Screenplay 設計を取り入れた経験を紹介しました。 ぜひ、参考にしてみてください。

テスト

-

シェアする

フォローする

次のページ

2023年の振り返り。家と車と私

前のページ

Qwikでブログページを刷新して学んだこと

関連する記事

タグ「テスト」の記事

CSS Layout Testing というテスト手法の提案

Web のフロントエンド実装において、次のようなミスによってデザイン崩れを起こしてしまったことはありませんか。 flex-shrink の指定を忘れて、要素が押しつぶされてしまった z-index の指定を間違えて、要素が意図せず前面(また

2026-01-10

フロントエンド
テスト
単体テストを全通り書くんじゃない!

AIの進化によって、プロダクションコードに対するテストコードは、以前と比べて格段に書きやすくなったと感じています。 単体テストに関する基本的なお作法については、以前に以下の記事で整理しました。 興味があれば、参考として読んでもらえると嬉しい

2026-01-09

テスト
Playwright の POM を Storybook 上で確認してから E2E テストを書く

はじめに Playwright で E2E テストを書く際、playwright codegen や、近年では Playwright MCP を利用して、テストコードの雛形を作成することが多いと思います。 ただし、生成したテストコードが正し

2025-12-26

テスト
← ブログ一覧へ