なぜE2Eテストでidを使うべきではないのか

Autify, Inc.

こんにちは。AutifyのSET(Software Engineer in Test) 、末村(@tsueeemura)です。

皆さん、E2Eテストしてますか?以前はほぼSelenium一択みたいなところがありましたが、最近はPuppeteerCypressTestCafeなどいろいろなフレームワークがあり、ついつい目移りしてしまいますね!

さて、どのフレームワークを使うにせよ、E2Eテストを書く上で共通で意識しないといけない重要なファクターがいくつか存在します。

その一つが ロケータ です。操作や検証の対象となる要素を指定するためのキーのことです。 ロケータにはCSSセレクタやXPathが利用でき、idclassname といった属性を利用するのが一般的です。

今回はこのロケータについての話を書こうと思います。

ロケータとは

要素を一意に指定できさえすればロケータに使うものは何でも良いのですが、保守性などの観点から、以下のような条件を満たす必要があります。

  • 常に一意である
  • 変更される可能性が少ない

例えば、以下のサンプルコードでは btn-primary というクラスを使って要素を特定しています。

<button class="btn-primary">送信</button>COPY

driver.getElementsByclassName("btn-primary") // btn-primaryというclassを持つ要素を指定するCOPY

しかし、classはボタンのスタイルに密接に関連し、ページ内で複数回登場する可能性がありますし、将来変更される可能性が高いです。 そのため、E2Eテストにおいては id を使うことがベストプラクティスとされてきました。

<!-- submitというidを追加した -->
<button id="submit" class="btn-primary">送信</button>
COPY

// classではなくidでの指定に変える
driver.getElementByid("submit")
COPY

なぜidを使ってはいけないのか

ですが、現代の複雑なWebアプリケーション開発において、 id をテストコードから参照するのは問題があると思っています。

その理由の一つは、idの変更を伴う修正が困難になる ためです。本来内部的な値としてしか意図していない id を、テストコードなどの外部のコードから参照してしまうと、プロダクトコード側のメンテナンス性を下げることに繋がります。

極端な例ですが、JavaScriptフレームワークのアップデートにより、全ての id に特定のプレフィックスが付くようになったり、 id がフレームワークによりコントロールされてしまい、ビルドのたびに変更されるようなパターンを想像してください。

こうした破壊的変更が起きたとき、E2Eテストに求められるのは振る舞いが変わっていないことの検証です。しかし、ロケータが id に依存している場合、たとえ振る舞いが一切変わっていなかったとしても、テストコード側では 全てのロケータの再定義 が必要になります。

加えてE2Eテスト特有の性質である実行速度が遅い 実行タイミングが限られるということからくる問題があります。つまり、開発者がリファクタリングの中でidを変更した場合、それがテストコードやアプリケーション全体の動作に与えている影響が分かるのは、一通りの開発が終わり、ビルドが通り、ブラウザ上でWebアプリが動作し、E2Eテストが完了した後です。このような環境では、idの変更を伴うリファクタリングは事実上困難です。

(余談ですが、よくE2Eテストは腐りやすいと言われるのはこれらの特性に起因すると考えています)

意味や振る舞いに着目する

id がもはや不変のものではなく、むしろ開発者の都合で容易に変わるとしたら、E2Eテストにおいては何がロケータとして適切なのでしょうか?ここで、前章で登場したこの記述を改めて見直してみましょう。

こうした破壊的変更が起きたとき、E2Eテストに求められるのは振る舞いが変わっていないことの検証です。しかし、ロケータが id に依存している場合、たとえ振る舞いが一切変わっていなかったとしても、テストコード側では 全てのロケータの再定義 が必要になります。

言い換えれば、E2Eテストにおいては、 振る舞いこそが正義 で、振る舞いが変わった時にこそ追従すべきと考えられます。そのため、 idclass といった内部的な要素に依拠するのではなく、要素の持つ意味や振る舞いに着目してロケーティングするのが最も自然です。

文言を用いたロケーティング

意味や振る舞いに着目したロケーティング手法の一つとして、文言によるロケーティングがあります。

唐突ですが、E2Eテストフレームワークの Cypress のドキュメンテーションに、要素選択におけるベストプラクティスを紹介した Best Practices – Selecting Elements という記事があるのをご存知でしょうか。この記事にも、idclass に代わるものの一つとして、文言が紹介されています(以下、()内は筆者による訳)

  • Don’t target elements based on CSS attributes such as: id, class, tagid, class, tagのようなCSS由来の属性は使わない)
  • Don’t target elements that may change their textContent (変更が予測されるtextContentは使わない)
  • Add data- attributes to make it easier to target elements (要素を簡単に特定するために `data-` 属性を定義する)

ここでは data-* 属性というものが推奨されており(※data-*属性については後の章で触れることにします)、文言でのロケーティングは推奨されていませんが、一方で次のような記述もあります。

If the content of the element changed would you want the test to fail? (要素内のテキストが変更されたときにテストが失敗して欲しいですか?)
  • If the answer is yes: then use cy.contains() (はい→ cy.contains() を使いましょう)
  • If the answer is no: then use a data attribute. (いいえ→ data 属性を使いましょう)

引用中で登場した cy.contains() というのは、要素が含む文言を使ってロケーティングするためのCypressのメソッドです。 Seleniumにも Find Element By Link Text というものがありますが、こちらは <a> タグのテキストのみを対象にする一方、 cy.contains() はtextContent全てを対象にできます。

cy.contains() を使い文言によるロケーティングを行うと、先程のサンプルコードのうち、 送信 ボタンの探索は以下のように書き換えられます。

<button type="submit" data-qa="submit">送信</button>COPY

cy.contains('送信')COPY

文言をロケータに使うメリットは、先に述べたように 要素内のテキストが変更されたときにテストが失敗する 点です。要素の外部的な振る舞いを保護できるとも言い換えられます。つまり、送信ボタンに null などの不正な文字列が入っていないことを検証するために、わざわざ assert(button.text === '送信') のようなアサーションを入れる必要が無くなります。

文言をロケータに使うもう一つのメリットは、要素の内部的な振る舞いに一切関わらない点です。開発者がE2Eレイヤーのテストに期待するのは、プロダクトコード側で変更した内容が、アプリケーションの全体的な動作に影響していないことの保証です。そのため、本来であればidなどの要素の内部的な値は一切使うべきではありません。

文言を主なロケータとして採用すると、例えばJavaScriptフレームワークのリプレイスを伴うような大きな変更であっても、アプリケーション全体の操作フローが変わらなければ、(理論的には)テストコード側の修正は不要になります。

要素の一意性を担保する

文言によるロケーティングは非常に有用である一方で、欠点として 一意性の担保が難しい 点が挙げられます。例えば、送信ボタンをクリックした後、確認ダイアログが出る UIを想定してみましょう。

<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#exampleModal">
 送信する
</button>

<div class="modal" tabindex="-1" role="dialog">
 <div class="modal-dialog" role="document">
   <div class="modal-content">
     <div class="modal-header">
       <h5 class="modal-title">確認</h5>
       <button type="button" class="close" data-dismiss="modal" aria-label="Close">
         <span aria-hidden="true">&times;</span>
       </button>
     </div>
     <div class="modal-body">
       <p>本当に送信しますか?</p>
     </div>
     <div class="modal-footer">
       <button type="button" class="btn btn-primary">送信する</button>
       <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
     </div>
   </div>
 </div>
</div>
COPY

この場合、DOM内に 送信する ボタンが2つ存在するため、どちらのボタンをクリックすべきか明示する必要があります。例えば、モーダル内の 送信する ボタンに限定するには、次のようにモーダルとボタンによってロケーティングできます。

Cypressには、このようなパターンに対応するための within という構文があります。例として、modalタグ内の送信ボタンに限定させてみましょう。

cy.get('.modal').within(() => {
 cy.contains('送信する').click()
})
COPY

cy.get('.modal') で、modal classを持つ要素内に探索範囲を限定させた上で、cy.contains('送信する') で送信ボタンを探索しています。 このような記述方法なら、マッチする文言が複数存在していても問題ありませんし、人間の目で探索するときの確認モーダル内の送信ボタンをクリックするという順序に合わせて記述することができ、可読性が高いです。

data-*属性

ところで、先述したCypressのドキュメント内で、 data-* 属性なるものが紹介されていたのを覚えていますか?

  • Don’t target elements based on CSS attributes such as: id, class, tag
  • Don’t target elements that may change their textContent
  • Add data-* attributes to make it easier to target elements

ここでは、id, classtagなどのCSS由来の属性や、変わるかもしれない要素内のテキスト(textContent)をロケータとして使わず、 data-* 属性を定義するべきと書かれています。

data-* 属性とは、ざっくり言うと開発者が独自の属性をタグ内に準備するための仕組みです。例えば、次のようにテスト対象の要素に data-qa というカスタム属性を定義してみます。

<input type="text" name="first_name" data-qa="first_name">
<input type="text" name="last_name" data-qa="last_name">
<button type="submit" data-qa="submit">送信</button>
COPY

すると、要素の探索は以下のようなロケータで行うことが出来ます。

// cy.get() はCSSセレクタで要素を探索するためのメソッドです

cy.get('[data-qa=first_name]')
cy.get('[data-qa=last_name]')
cy.get('[data-qa=submit]')
COPY

これにより、例えばプロダクトコード側のリファクタリングにより、name属性の命名規則がスネークケース first_name からキャメルケース firstName に変わったとしても、data-qa属性を変えなければテストコードの動作には影響がありません。

data-*属性を使うときの問題点

この方法は一見完璧に見えますが、一方で管理コストの増大を招きます。data-* 属性を使う最大の欠点は、これらのカスタム属性をプロダクトコード側で管理しておく必要がある点です。

例えば、開発者はdata-qa属性の値が常に画面内で一意性を保てるように管理しないといけません。次の例では、新たに 入力例 というフォームを作り、 first_name などの値が重複してしまったために、テストが失敗してしまいます。

<!-- 新しい"入力例"というフォームが作られたことによって既存のテストが失敗する -->

<span>入力例:</span>
<input data-qa="first_name" disabled value="Takuya">
<input data-qa="last_name" disabled value="Suemura">

<input type="text" name="first_name" data-qa="first_name">
<input type="text" name="last_name" data-qa="last_name">
<button type="submit" data-qa="submit">送信</button>
COPY

data-*属性はプロダクトコード側に影響を及ぼさない形で id に代わるものを用意する方法として有用ですが、結局 iddata-* の二重管理になってしまったり、テストコード側との整合性が取りづらかったりするという欠点があります。

data-*属性の使い所

前述の通り、 data-* 属性を id のように使っていくのは管理コストの面で問題があるため個人的には反対なのですが、UIコンポーネントに意味づけをするための補助的な役割であれば効果が期待できます。

例えば、文言でのロケーティングにおいての一意性担保において、以下のようなサンプルコードが登場しました。

cy.get('.modal').within(() => {
 cy.contains('送信').click()
})
COPY

見ての通り、このコードではモーダルのロケーティングに .modal という class を参照してしまっています。モーダルが .modal というclassを持っているかどうかは振る舞いにおいてはあまり意味がないので、この記述は将来的にテストコードを壊しうる保守性の低い記述です。

そのため、こうした大きなコンポーネントに対して data-* 属性を付与すれば、プロダクトコードの管理コストに大きな影響を与えず、テストコードの保守性を高められます。

<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#exampleModal">
 送信する
</button>

<div class="modal" tabindex="-1" role="dialog" data-qa="modal">
 <div class="modal-dialog" role="document">
   <div class="modal-content">
     <div class="modal-header">
       <h5 class="modal-title">確認</h5>
       <button type="button" class="close" data-dismiss="modal" aria-label="Close">
         <span aria-hidden="true">&times;</span>
       </button>
     </div>
     <div class="modal-body">
       <p>本当に送信しますか?</p>
     </div>
     <div class="modal-footer">
       <button type="button" class="btn btn-primary">送信する</button>
       <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
     </div>
   </div>
 </div>
</div>
COPY

こうすると、テストコードは以下のように書き直せます。

cy.get('[data-qa=modal]').within(() => {
 cy.contains('送信').click()
})
COPY

言うまでもないですが、追加した data-* 属性についてチーム内で共有されていなければリファクタリングの際などに消されてしまう恐れもありますので、こうした点についてドキュメンテーションを行うことも必要です。

まとめ

ここまでで、次のようなことについて説明してきました。

  • アプリケーションにおいて不変なものは idclass ではなく 振る舞い である
  • 振る舞いが変わった時、E2Eテストはそれを検知できる必要がある
  • E2Eテストは外部的な振る舞い以外の何物もロックすべきではない

こうした点から、自分の考える最良のロケーティング戦略は次のようなものになります。Cypressが提唱するBest Practicesに近いですが、文言をより重視します。

  • 原則として要素の文言を使いロケーティングする
  • data-*属性はUIコンポーネントの抽象化などの限定的な用途に用いる

この戦略を利用すると、次のようなメリットが得られるようになります。

  • idclass, nameなどの内部的な値を使わない為、プロダクトコード側の変更容易性を保てる
  • data-*属性を多用する場合に比べ、プロダクトコード側のメンテナンスコストが低くなる
  • 文言の変更に対して追加のアサーションが不要になり、振る舞いの変更に強くなる

おまけ1: どのテストフレームワークを使うべきか

文言でのロケーティングはXPathを使えば実現できるため、理論上はどのようなテストフレームワークを使っても実現できるのですが、本記事で取り上げた Cypress のようにテキストベースのロケーティングに対応しているものを使うことをおすすめします。

また、個人的には CodeceptJS を強くおすすめしています。Locator Builder の記法が宣言的で分かりやすい点と、Semantic Locator の仕組みにより標準的なDOM構造のサイトであれば純粋に文言のみでテストを記述していける点からです。

// Locator Builderの例
// CSSセレクタやXPathだと複雑になりがちなロケータをシンプルに書ける
locate('a')
 .withAttr({ href: '#' })
 .inside(locate('label').withText('Hello'));

// Semantic Locatorの例
// 標準的なDOM構造のサイトであれば文言を指定するだけで操作できる
//
// "Sign in" ボタンをクリックし、
// "Username" フィールドに "tsuemura" と入力する
I.click('Sign in');
I.fillField('Username', 'tsuemura');
COPY

CodeceptJSには先述の within などの記法ももちろん存在しますので 、本記事でご紹介した方法を実践するのに最適です。ぜひ試してみて下さい。

おまけ2: Autifyにおけるロケータの概念

Autifyは Selenium IDE のようないわゆるRecord&Playback系のソリューションを提供していますので、ユーザーが直接ロケータを記述することはありませんが、本記事で紹介したような方法を一部取り入れています。

Autifyでは、テストシナリオのレコーディングの際に、要素が持つ様々な属性を収集します。その中には、id, class, name, タグなどのメタ情報の他に、文言や座標などユーザーから見えている情報も含んでいます。 そして、テストコードを実行する際に、画面上の要素それぞれについてこれらの属性との一致度を測定し、もっとも強く一致する要素を採用します。

この手法は、主にAIによるロケータの自動修復(セルフヒーリング)に使われていますが、副次的な効果として プロダクトコードの変更容易性を高く保つ ことにもつながっています。本記事で紹介したような、文言やdata-* 属性でのロケーティングから更に一歩進んで、idclassのみならず文言などについてもプロダクトコードをロックしません。

Autifyはいわゆるノーコード型のソリューションにカテゴライズされることが多く、非エンジニア向けのソリューションとみなされていますが、一方でこのように従来型のテスト自動化技術の欠点を克服し、世界中の開発生産性を向上することも目指しています。

Autifyではこの他にもマーケティングやセールスに役立つ資料を無料で公開していますので、ぜひこちらからご覧ください。