はじめに
E2Eテストコードを書くにあたって、一番の心配事は テストが壊れる ことでしょう。
アプリ側の変更に伴いテストをすべて書き直すことを恐れるQAエンジニアは多いと思います。
例えば 機能はまったく変わってないのに、デザインリニューアルでテスト全部書き直しだ〜〜〜! みたいなやつですね。
ここでは、自分が実際に「メンテしやすい」テストコードを試行錯誤する過程で得られた気づきをまとめていきたいと思います。
なお、自分の活動範囲は主にWebアプリのテストですので、基本Webアプリの話題になりますが、「いやネイティブアプリはこうやねん」みたいなツッコミもお待ちしております。
また、文中に登場するサンプルコードは CodeceptJSで記述しています。
使ったことがない方は気合で読んでください。
メンテしやすい vs メンテナンスフリー
はじめに強くお伝えしておきたいことは、メンテナンスしやすいことと、メンテナンスが不要であることは全く違うということてす。
テストコードとアプリケーションコードがまるで別のものであるように捉える人もいますが、テストを書いた時点でアプリケーションとテストの間には依存関係が生まれます。
依存関係にあるコードの片方を修正すれば、もう片方も追随する必要があります。これはプログラミング全般に言えることです。
つまり、テストコードだからといって普通のコードと違って特別なことがあるわけではないのです。
アプリケーションの振る舞いを直せばテストも直さなくてはいけないし、逆も同様です。
ですので、本当に心配すべきことはテストが壊れることではなく、いかにしてアプリケーションの変更に(ストレスなく)追随するかです。
ここを履き違えてしまうと、「メンテナンスフリーのテストコードを実現する!」というよくわからない実現不可能な目標を延々と追い続けることになってしまいます。
ロケータには何を使えば良いのか
さて、先に挙げた例をもう一度見てみましょう。
機能はまったく変わってないのに、デザインリニューアルでテスト全部書き直しだ〜〜〜!
経験のある方は察していただけるかと思いますが、これは要素の選択に何を使っているか、つまり ロケータ に問題がありそうですね。
具体的には、次のようなCSSセレクタをロケータを使っていると、デザイン変更の影響を大きく受けてしまいます。
button.btn.btn-primary.btn-large
この手の議論で良く紹介されているベストプラクティスは、class
ではなくid
を使用すべきということですね。
では、本当にid
を使うとメンテナンス性が高まるのでしょうか?いついかなるときも id
を使うのが正しいのでしょうか?
テスト観点を要素の選択方法に反映させる
ロケータの問題を考える前に、まず「テスト観点」について考えてみましょう。
次のテストケースのうち、あなたはどの書き方がしっくりきますか?
- 送信ボタンを押すとエラーメッセージが表示される
- 青いボタンを押すと、赤いエラーメッセージが表示される
-
送信
を押すとこのメールアドレスは使用済みです
と表示される
この問いかけに正解はありません。
これは、あなたが「どのような観点でテストを書きたいと思っているか」を表すものです。
例えば、
送信ボタンを押すとエラーメッセージが表示される
を選んだ人は要素そのものに着目していますし、
送信
を押すとこのメールアドレスは使用済みです
と表示される
を選んだ人は文言に着目しています。
では次に、あなたの選んだテストケースに対し、しっくりくるテストコードはどれでしょうか?
I.click('#submit')
I.seeElementExists('#errormsg')
I.click('button[data-test=submit]')
I.seeElementExists('div[data-test=errormsg]')
I.click('button.btn-blue')
I.seeElementExists('div.alert.alert-red')
I.click('送信')
I.see('このメールアドレスは使用済みです')
この問いは、あなたが「保証したい」と思っている観点に対して、適切なロケータを採用できているかどうか、という問いかけです。
要素が表示されていることを保証したい のであれば ID
や data-*
を使うべきでしょうし、文言が表示されていることを保証したい のであれば文言そのものを使うべきでしょう。
(あまりないと思いますが)要素に特定のクラスが付加されていることを保証したいならば、クラスを使うべきです。
つまり、ロケータの定義一つ取っても、**「何を保証するのか」**が極めて大きく反映されてくるのです。
そして、それを取り違えた場合に、テスト対象と無関係な箇所の修正でテストコード修正を余儀なくされ、メンテナンスコストが大きく増えてしまうということになります。
さて、あらためて最初の例に立ち戻ってみましょう。
機能は変わっていないのにデザインの変更でテストコードのメンテナンスを余儀なくされるということは、本来依存関係を持たせるべきではない class
などの属性をロケータに使ってしまったということですね。
そうではなく、機能を表すもの(id
など要素そのものを指し示すものなのか、それとも文言や画像なのかはプロダクトやコンポーネントによって異なるでしょう)をロケータに使った場合、アプリケーションの変更をテストコードが検知することができ、お互いに良い関係を築くことが出来ます。
E2Eテストで何をテストするのか、誰のためにテストするのかはチームやプロダクトによって異なると思いますので、ある選択がいついかなるときも最適であるとは限りません。
自分は社内では 文言での要素選択 を強く推進していますが、それは E2Eテストはユーザー目線で行われるものであり、要素の選択はユーザーと同じ基準で行われるべき という思想に基づいています。
チームによってはidやdata-*属性を利用したほうが良いことも当然あるでしょう。
ロケータの問題は、テストに取り組むエンジニア全員が一度は通る道と言っても過言ではありません。
テスティングフレームワーク Cypress の公式サイトでは、この問題に対するベストプラクティスを非常に良くまとめてくれています。
https://docs.cypress.io/guides/references/best-practices.html#Selecting-Elements
テストコードはアプリケーションコードと依存関係を作ることで、アプリケーションを意図しない変更から保護します。
そして、ロケータは依存関係をもたらす要素の一つです。メンテナンスに疲れたら、ぜひ一度ロケータを見直してみてください。
PageObjectPatternは使ったほうが良いのか
さて、話は変わって、E2Eテストコードのメンテナンス性を高めようとしたとき、一番最初に使われるのが PageObjectPattern
ではないでしょうか。
ご存じない方のために簡単に説明しておくと、 PageObject
とは、ロケータや操作などをページ単位でオブジェクトにまとめたものを指します。
const page = {
nameInput: locate('input#name'),
passwordInput: locate('input#password'),
submitButton: locate('button#submit'),
submit: (name, password, submit) => {
I.fillField(nameInput, name)
I.fillField(passwordInput, password)
I.click(submitButton)
}
}
submit()
なぜPageObjectPatternを使うと可読性やメンテナンス性が向上するのかというと、基本的には世の中で言われているオブジェクト志向の利点である
- カプセル化
- 再利用
あたりがそのまま適用されると思っていただければOKです。
つまり、ロケータを隠蔽し、要素を抽象化することで、デザインの変更があったとしてもテストコードそのものには影響せず、PageObjectの方を修正すれば良いので、メンテナンス性と可読性が向上するという考え方です。
また、操作を抽象化すれば、一度記述した操作を他のページでも再利用できるので、コード量を減らすことが出来ます。
一見素晴らしいものに見えますね。実際、きちんと運用できれば可読性と再利用性を上げ、メンテナンスを楽にしてくれます。
さて、なぜここでPageObjectPatternの話を始めたかというと、端的に運用がつらいからです。
E2Eテストの場合、そこまで再利用性が求められることも少なく、アプリケーションコードほどDRYに書く必要はありません。
むしろ、手続き型でゴリゴリ書いたほうが読みやすいことのほうが多いです。
テストコードは具体的に書くものなので、過度な抽象化はメンテナンス性を損ねるというのが私の意見です。
というわけで、ここではPageObjectPatternに嫌気が指してしまった人のために、よりスマートな方法を提案してみたいと思います。
もちろん、提案した方法がいついかなるときにもフィットするとは限りませんので、適材適所、ケースバイケースでご利用ください。
要素を抽象化する vs コンポーネントを抽象化する
PageObjectを書き始める最大の動機は、ロケータが長大で複雑怪奇になってしまい、変数に入れたり何かしら別名をつけたりして扱いやすくしたいということだと思います。
const page = {
firstNameInput: locate('div').withText('名前').find('input'),
lastNameInput: locate('div').withText('名字').find('input'),
jobSelect: locate('div').withSelect('職業').find('select'),
jobSelectOptions: {
qa: locate('div').withSelect('職業').find('select').find('option').withText('QAエンジニア'),
tester: locate('div').withSelect('職業').find('select').find('option').withText('テスター'),
}
}
I.fillField(firstNameInput, '龍')
I.fillField(lastNameInput, '神')
I.click(jobSelect)
I.click(jobSelectOptions.tester)
ですが、テストコードの中で出現するロケータの数は場合によっては数十にも及ぶので、要素の数だけテストシナリオと直接関係しない新しい名前が爆誕していくという悲しみがあります。
「えーーーーーーっと名字欄はどれだっけ……lastNameInput
か」みたいな調子ですね。
どちらかというと、必要なのは 「ロケータを返してくれる関数」ではないかなと思います。
どういうことかというと、こういうことです。
const findInputByLabel = label => locate('div').withText(label).find('input')
I.fillField(findInputByLabel('我が名は'), '神龍')
上記の例では、文字列を与えると、その文字列を含む <div>
要素の中から <input>
要素を探索し結果を返却する findInputByLabel()
という関数を定義しています。
要素そのものを抽象化するのではなく、コンポーネントを抽象化するイメージですね。
特に入力項目が多いフォームなどで、ロケータを一つずつ定義していくのは非常に辛い作業ですが、関数を定義するのであればコンポーネント1つに対して関数を1つ定義すればOKです。
データドリブンテストを使う
PageObjectを利用する動機として、もう一つ、「一度記述したテスト手順を再利用したい」というものがあります。
常日頃「コピペは悪」と教えられてきている身としては、コピペはできるだけ避けていきたいものですね(おれはコピペプログラマーですが……)。
ページに対する操作を抽象化することで起きる弊害の一つが、「テストコードが追いにくくなる」ということですね。
テストは基本的に手続き的に行われるものなので、冗長になることは承知の上で 再利用?猫にでも食わせとけ
くらいの気持ちで書いたほうが楽なときもあります。
さて、一度記述したテスト手順を再利用するということは、恐らくは「手順は同じだがいろいろなデータで検証したい」ということですね。
例として、「名前を入れるとそれに対応したメッセージが表示される」機能のテストをしてみます。
const page = {
fillIn: (name) => {
I.fillField('名前を入力してください', name)
I.click('送信')
}
}
Scenario('太郎の場合', async (I) => {
page.fillIn('太郎')
I.see('やあ!ぼくのなまえは太郎だよ!')
})
Scenario('花子の場合', async (I) => {
page.fillIn('花子')
I.see('こんにちは!私の名前は花子よ!')
})
これをデータドリブンで記述するとどうでしょう。
const userList = [
{ name: '太郎', message: 'やあ!僕の名前は太郎だよ!' },
{ name: '花子', message: 'こんにちは!私の名前は花子よ!' },
]
Data(userList).Scenario('メッセージのテスト', async (I, current) => {
I.fillField('キャラクターの名前を入力してください', current.name)
I.click('送信')
I.see(current.message)
})
簡単に説明しておくと、 Data(datalist)
の引数として渡した配列 datalist
はイテレートされ、配列の数だけシナリオが繰り返し実行されます。
配列の要素は current
という変数に格納され、シナリオ内で利用できるようになります。
もちろん、データドリブンとPageObjectは相反する概念でも何でもなく、併用することも全然アリなのですが、目的が 操作を抽象化すること
ではなく 様々なデータを用いる
ことなのであれば、どちらを使うべきかは自明ですね。
E2Eテストに頼らない
最後に、逆説的ですが、E2Eテストに頼りすぎたがために、E2Eテストのメンテナンス性が悪くなるケースは往々にして存在します。
よく言われることですが、E2Eテストではなるべくスモークテストなどのクリティカルな部分や、クロスブラウザでのビジュアルリグレッションテストなどE2Eでしか出来ない部分に注力し、それ以外は出来る限り低いレイヤーのテスト(例えばユニットテストなど)に落とし込んでいきましょう。
従来E2EでカバーしていたようなUIコンポーネントのテストなども、最近はユニットテストできるようになっているようです(自分はフロントエンドよく分からないので、チームメイトからちょっと聞きかじったくらいの知識しか無いですが……)。
Vue.jsの公式ドキュメントにも記載がありますね。 https://jp.vuejs.org/v2/guide/unit-testing.html
まあ、レガシーなプロジェクトの保守・改善・機能追加などを任されて、とりあえずE2Eでがっつり保護しないといけないみたいなパターンもあるあるだと思うので、この辺はもうトレードオフというか、アレですね、がんばりましょう。
おわりに
E2Eテストはどうにも「テストを自動化して退屈な手動テストから脱却しよう!」という、RPAの延長上のような括られ方をしてしまいますが、実際はユニットテストなどと同様「アプリケーションコードと対になり、ソフトウェアの骨組みを構成するもの」です。
そのように、エンジニアリングの一環として考えていけば、E2Eテストの目的や性質はクリアになり、メンテナンスしやすいコードも自ずと書けるようになるだろうと信じています。
ちなみに、このように偉そうなことを書いている私ですが、実際は毎朝CIが落ち、そのたびにいまいち補完の効かないテストコードを頑張ってgrepしながら修正する楽しい日々を送っています。
現実は厳しい。