📄

Context Enclosure でE2Eテストの画面遷移を表現する

2022/02/27に公開

はじめに

CodeceptJS はブラウザE2Eテストのためのフレームワークだ。出来ることは他のフレームワークと変わらず、ブラウザを操作して期待値を記述していく、ただそれだけなのだが、記法にちょっとクセがある。次のサンプルコードを見てほしい。

// "https://example.com" にアクセスする
I.amOnPage("https://example.com")

// "Example Domain" という文字列が表示されることを確認する
I.see("Example Domain")

他のフレームワークなら browser に相当する操作の起点が I という一人称になっている。続くメソッドは動詞から始まり、引数は目的語となり、英文の形を取る。例えば、あるサイトにアクセスする場合は I am on page "https://example.com" となり、ページ内の文言を確認するときは I see "Example Domain" となる。

これはテスト手順をそのままテストコードに落とす上で非常に有益である一方、Page Object Pattern との相性が非常に悪い。もちろんPageObjectを使って書くことは出来るのだが、英文として不自然な書き方になってしまう。

例えば、ログイン画面における操作をページオブジェクトにした LoginPage を定義したとする。


const LoginPage = {
  get emailInput() { return locate('input[name=email]') },
  get passwordInput() { return locate('input[name=password]') },
  get submitButton() { return locate('button[type=submit]') },

  login: function (email, password) {
    I.fillField(this.emailInput, email)
    I.fillField(this.passwordInput, password)
    I.click(this.submitButton)
  }
}

const email = 'foo@example.com'
const password = 'Password'

LoginPage.login(email, password)

テストコード本体は LoginPage.login(email, password) となる。これを英文として扱うと、 LoginPage login "email" "password" となってしまい、意味が通らない。あくまで主語は I として扱いたい。


ところで、CodeceptJSには within という機能がある。これは、要素探索を特定の要素の中に限定したり、 iframe 要素内のdocumentにスイッチするための機能である。以下の例では、ログインフォームの中に操作(=要素探索の範囲)を限定している。

// ログインフォームの中に操作を限定する
within('form#login', () => {
    I.fillField('email', 'foo@example.com'),
    I.fillField('password', 'Password')
    I.click('Sign in')
})

この書き方はとてもわかりやすく、気に入っている。ログインフォームが表示されていることを期待している、ということがコードで端的に表現されており、可読性が高い。

PageObjectを利用する場合も、「あるページにいる状態」を定義して、その中で操作するようにすれば、英文法としても自然に見える。

// こんな風に書けるとよさそう?
I.amOnLoginPage(() => {
   I.login(email, password) 
})

というわけで、本稿でトライするのは、英文法として自然に読めるPageObjectの実装である。オリジナルの PageObjectPattern との区別のために、 仮に Context Enclosure と呼ぶことにする。コンテキストを明示するためにスクリプトを「囲う」ものという意味合いである。
以下のように実装する。

  • amOn~~~Page() という記法を定義する
  • ページ内での操作はコールバックとして渡す
  • ページ固有の操作やロケーターをPageObjectの中で定義する

ContextEnclosure を定義する

ベースになるテストコードは以下の通り。テスト対象には HOTEL PLANISPHERE を用いる。

Feature('Example');

Scenario('Login', ({ I }) => {
  I.amOnPage('login')
  I.fillField('Email', 'clark@example.com')
  I.fillField('Password', 'password')
  I.click('Login', 'form')
  I.see('Clark Evans')
});

このテストコードは、以下のような手順で構成されている。

  • login に遷移する
  • メールアドレス clark@example.com を入力する
  • パスワード password を入力する
  • ログインボタンをクリックする
  • Clark Evans と表示されていることを確認する

だが、実は「login に遷移する」以降のステップがログインフォーム内の操作であることはテストコードの中には全く書かれていない。このテストが動作するのは、たまたま I.amOnPage('login') の結果、その後のテストコードで期待しているページに遷移していたというだけである。

そこで、まずはログインページに遷移していることを表明するコードにしてみる。例えば、以下のように書けば、あるコードがログインページの中で動作することを表明できるだろう。

Feature('Example');

Scenario('Login', ({ I }) => {
  I.amOnLoginPage((I) => {
    I.fillField('Email', 'clark@example.com')
    I.fillField('Password', 'password)
    I.click('Login', 'form')
    I.see('Clark Evans')
  })
});

さて、 ContextEnclosure は前述の通り I から生やす形で実装したい。そのため、カスタムコマンドとして steps_file.js に以下を実装する。

// in this file you can append custom step methods to 'I' object

module.exports = function() {
  return actor({
    amOnLoginPage: function (fn) {
      const I = actor({})
      I.amOnPage('login')
      return fn(I)
    },
  });
}

actor() はCodeceptJSが提供するAPIで、 I オブジェクトを拡張するためのものである。この中にメソッドを定義すると、実行時に I オブジェクトに反映される。

また、actorの中で I を呼び出すために、メソッドの中でも I を定義している。これをしなくても、例えば this.amOnPage('login') と書くことも出来るのだが、読みやすさと、後述の「特定のページ内でのみ有効なメソッド」の実装のために、このようにしている。

この時点では、このページオブジェクト amOnLoginPage は何もしていない。しかし、メールアドレスの入力などの手順が、サイト内のどのページで行われるべきかを、コードで表現出来ており、可読性が高い。

Context Enclosure にメソッドを定義する

続いて、ログインページにいる間にのみ利用できるメソッド fillForm() および postForm() を定義する。やることはシンプルで、先程の例と同様に actor() を用いて I オブジェクトを拡張する。ただし、今度は amOnLoginPage() の中で拡張し、それをコールバック関数に渡すようにする。

module.exports = function() {
  return actor({
    amOnLoginPage: function (fn) {
      const I = actor({
        login: function (email, password) {
          I.fillField('Email', email)
          I.fillField('Password', password)
          I.click('Login', 'form')
        }
      })
      I.amOnPage('login')
      fn(I)
    } 
  });
}

テストコードはこんな風になる。

Feature('Example');

Scenario('Login', ({ I }) => {
  I.amOnLoginPage((I) => {
    I.login("clark@example.com", "password");
    I.see("Clark Evans")
  })
});

この login というメソッドは、ログインページの中でしか利用できない。

テストコードを書く時にカスタムコマンドをやたらと定義してしまうと、そのコマンドが今いる画面で利用可能なのかどうかが分からなかったりして、最終的に実装を見に行くことになってしまって大変だったりする。メソッドの利用範囲をページに閉じることで、そういった手間はなくなる。

この辺りは PageObject の利点でもあったが、 PageObject の場合はそれ自体がスクリプトの起点となってしまい、特殊なケースでの拡張性が低いことがあった。例えば、ログインページの中で 「 email を入力したあと Forgot Password? をクリックした際に email が次のページにも引き継がれる」ようなケースを書きたい場合、冒頭で定義したような PageObject の定義では一部だけが LoginPage に属さないもののように見えてしまう。

I.fillField(LoginPage.emailInput, email)
I.click('Forgot Password?') // この `Forgot Password?` はログインページのものなのだろうか?
I.seeInField(LoginPage.emailInput, email)

Context Enclosure の場合、スクリプト全体を囲っているので、すべての要素が ログインページにいる というコンテキストに属していることを表している。異常系などのケースを書き足したい時に、わざわざ PageObject に追加する手間を省ける。

I.amOnLoginPage(I => {
  I.fillField(emailInput, email)
  I.filField('Forgot Password?')
  I.seeInField(emailInput, email)
)

コード補完を効かせる

ところで、このままだとコードの補完が効かないので、 amOnLoginPage() に型定義を与えておこう。

module.exports = function() {
  return actor({
     /**
     *
     * @param {function(I): void} fn
     */
    amOnLoginPage: function (fn) {
      const I = actor({
        login: function (email, password) {
          I.fillField('Email', email)
          I.fillField('Password', password)
          I.click('Login', 'form')
        }
      })
      I.amOnPage('login')
      fn(I)
    } 
  });
}

@param {function(I): void} fn という記述は、JSDoc を使って引数の型を定義するためのものだ。これで、 amOnLoginPage の中で、追加した loginメソッドを含め I のメソッドに補完が効くようになる。

試してみたい人向け情報

CodeceptJSとPlayWrightを用いて検証している。 codecept.conf.js は以下の通り。

const { setHeadlessWhen, setCommonPlugins } = require('@codeceptjs/configure');

// turn on headless mode when running with HEADLESS=true environment variable
// export HEADLESS=true && npx codeceptjs run
setHeadlessWhen(process.env.HEADLESS);

// enable all common plugins https://github.com/codeceptjs/configure#setcommonplugins
setCommonPlugins();

exports.config = {
  tests: "./*_test.js",
  output: "./output",
  helpers: {
    Playwright: {
      url: "https://hotel.testplanisphere.dev/en-US/",
      show: true,
      browser: "chromium",
    },
  },
  include: {
    I: "./steps_file.js",
  },
  bootstrap: null,
  mocha: {},
  name: "codeceptjs-pageobject-example",
};

めんどくさい人のためにサンプルリポジトリも用意しておいた。

https://github.com/tsuemura/codeceptjs-pageobject-example/

Cypressでもやってみたい人向けに

もちろん出来る。以下の例では、 onLoginPage というContext Enclosureをカスタムコマンドとして定義して、その中で showmessage という独自のコマンドを定義している。この showMessage コマンドは onLoginPage の中だけで利用できる。

// spec.cy.js
describe('empty spec', () => {
  it('passes', () => {
    cy.onLoginPage(cy => {
      cy.findByPlaceholderText("Username").type("foobar")
      cy.findByPlaceholderText("Email").type("foobar@example.com")
      cy.findByPlaceholderText("Password").type("Pass1234")
      cy.showmessage('Hello from context')
    })
  })
})

// commands.js
Cypress.Commands.add("onLoginPage", (fn) => {
  Cypress.Commands.add("showmessage", (message) => {
    cy.log(message);
  });
  cy.visit("https://demo.realworld.io/#/register");
  fn(cy);
});

多分他のフレームワークでもあまり労力をかけずに実装可能だろう。興味があればぜひチャレンジしてみてほしい。

Discussion