テスト自動化失敗談を共有しよう! by T-DASH カレンダー、2日目の記事になります。
はじめに
E2Eテストを書きましょう!とチームで決意し、実際に書いてみたものの、気づけばいつのまにか通らなくなっており、誰も直さないうちにそのまま忘れ去られる……というのは、E2Eテスト自動化のあるあるだと思います。
この記事では、メンテナブルなE2Eテスト実装について、一般的に言われることと、自身の失敗経験とを照らし合わせてみたいと思います。
E2Eテストについて
ここでE2Eテスト(End to Endテスト)と呼んでいるものは、なんらかのUI操作自動化ライブラリ(UI Automation, Puppeteer, Cypressなど)を使い、コードとしてユーザー操作シナリオをエミュレートするテストのことを言っています。
特に、ノーコードのシナリオ自動生成ツール利用を検討する場合は別のソリューションになりえると思いますが、今回はエンジニアがコーディングして作成するケースの想定になります。
E2Eテストはいつ壊れるか
通っていたはずのテストが通らなくなるトリガーとしては、以下のようなものがあるかと思います。
-
バグフィックスやリファクタリングにより、内部構造に変更が生じた
- 例)これまでdiv要素につけていた
class
を削除したらセレクタが機能しなくなった
- 例)これまでdiv要素につけていた
-
製品の機能追加やUI改善により、UI構造に変更が生じた
- 例)チェックボックスをプルダウンに変えたらテストが機能しなくなった
-
ブラウザなど実行環境のアップデートにより、操作方法に変更が生じた
- 例)ショートカットキーによる従来の操作が使えなくなっていた
-
製品の多機能化や実行環境の問題により、テスト実行に要する時間に変更が生じた
- 例)今までタイムアウトしていなかった処理がタイムアウトするようになった
以上をふまえて、壊れないテストを作るための方法について考えてみます。
idやclassをdomのセレクタとして使わない、は正しいか
よく聞くプラクティスとして、スタイルや実装ロジックで使用するための識別子(htmlならば id
や class
など)を、E2Eテストにおけるセレクタとして使用しない、というものがあります。
idやclass属性など、実装上の都合で使用するものをテストで使用するということは、すなわち実装の詳細に対するテストであり、E2Eでテストすべき粒度(ふるまいに対してテストする)に反します。
その結果、実装の変更により簡単に破壊されるテストになってしまう、という主張です。
ベターな方法としては、data-testid
など、テストで使うための専用の属性を付与して使う、ということになります。
この主張自体は正しく、実際意識すべきプラクティスのひとつではあると思います。
しかし、先ほどのテストが壊れる4つの原因を見てみると、1番の例(内部構造の変更)に対する対策にしかなっていないことがわかります。
たとえテスト専用のdata属性を使ったとしても、その部品自体がチェックボックスからプルダウンに変わった場合には、やはりテストは壊れてしまう のが普通です。
個人的にも、そういった原因でテストが壊れ、メンテの手間がかかりすぎるようになってやがて捨てられる、という失敗を多く経験しました。
テストは壊れる
個人的な経験則から出した結論は、テストは壊れる、ということです。
壊れにくいテストを書く意識を持つことはもちろん大事ですが、もっと大事なのは 壊れてもメンテに手間のかからないテストを書くこと だと思っています。
具体的には、UI操作を適切な単位で共通化すること が戦略となります。
時々、「テストコードはDRYにしないほうがいい」という意見を見かけることがあるのですが、この言い方には勘違いが含まれていることがあると思っています。
テストコードでAssertする期待値を算出するロジックを共通化して「DRY」にしてしまうと、それは過剰だというのはわかります。
テストコードで期待値を算出するロジックを作ってしまうと、極端な話、製品コードでやっているのと同じことをテストコードでやることになり、そもそもそのテストコードのロジックはテストされてるの?という話になるため、期待値はベタ書きせよ、という話は理解できます。
しかし、テストコード内の手続き(E2Eならば画面の操作)は適切に共通化すべきであり、それが原因で可読性が下がるということはありません。
可読性が下がるとしたら、命名がおかしいなどの別の要因です。
Page Object Pattern
この「UI操作のコードを共通化する」というプラクティスを言語化したデザインパターンとして、「ページオブジェクトパターン」というものがあります。
操作対象となるページ単位で、それを操作するためのオブジェクトを用意し、publicメソッドとして画面内の操作を実装する、というものです。
一般的に、チェーンにして書けるように、操作のためのメソッドは自身のオブジェクトを返すようにします。
↓こんなイメージです(特定のフレームワークを意識していないので適当です)
// ユーザー登録ページオブジェクト
class UserRegisterPage {
// 氏名を入力
public typeName(name: string) {
find("input[testid=fullname]").type(name);
return this;
}
// メールアドレスを入力
public typeEmail(email: string) {
find("input[testid=email]").type(email);
return this;
}
// 性別選択
public selectGender(gendar: "male"|"female"|"other") {
find(`input[testid=${gendar}]`).click();
return this;
}
// 登録実行
public submit() {
find("button[testid=submit]").click();
return this;
}
// 結果メッセージを取得
public getResultMessage() {
return find("testid=result").waitUntilVisible().text;
}
}
テストコード側
const resultMessage = new UserRegisterPage()
.typeName("Yamada Taro")
.typeEmail("taro@example.com")
.selectGender("male")
.submit()
.getResultMessage();
assert(resultMessage, Is.equal("登録が完了しました"));
こうしておくと、たとえば性別の選択がラジオボタンからプルダウンに変わった場合でも、このページオブジェクト内の1メソッドを直すのみで済みます。
また、登録ボタンが活性化される前にボタンクリックしようとしてテストが時々こける、のような不安定なテストが後から見つかった場合でも、このボタンをクリックしているテストコードのあっちこっちを直す必要なく、このページオブジェクトのsubmitメソッド内で、ボタンが活性化されるまで待つ処理を追加するだけで解決できます。
個人的には、あまり「ページオブジェクト」という名前にとらわれず、より細かい単位で操作のためのオブジェクトを作っておくと便利だと思います。
特にUI部品としてサードバーティのライブラリ(インクリメンタル検索、ドラッグ&ドロップできるパネルなど)を利用する場合、その部品ごとに操作オブジェクトを作っておくと、ライブラリのバージョンアップなどにより構造が変わった場合にも改修が容易になります。
App Actions
Page Object Patternに変わるプラクティスとして、cypressチームがApp Actionsというものを提唱しています。
https://www.cypress.io/blog/2019/01/03/stop-using-page-objects-and-start-using-app-actions/
日本語の解説記事としては↓こちらが大変わかりやすいです。
Page Object Patternを使うな、というCypress公式記事を読んで思ったこと
一応個人的な感想のみ述べると、上記でPage Object Patternの問題点として挙げられている項目にはあまり共感できません。
cypressを使えば画面ごとにわざわざPageObjectを設ける必要もなく、コマンドレベルで簡単に操作を実装できる、というのが本旨なのかなと思いましたが、すでに述べたように、壊れるテストの保守しやすさという観点でいうと、操作を容易に記述できることはその解決策にはなりません。
以上
製品コードが破壊されることを防ぐための自動テストですが、テストコードが破壊されることの保守が新たなストレスとなると不幸です。
たかがテストコードと思わず、製品コードと同レベルで構造に気を配ることが、快適な自動テストの第一歩かなと思います。