フロントエンドで Selenium を使って TDD した話
本記事は Qiita の「テスト駆動開発 Advent Calendar 2020」の12月12日の記事です。
長くなってしまったので、目次だけでも読んでいってください!
経緯とモチベーション
2019年夏ころ~2020年夏ころまでの間、とあるSIerのWebアプリケーション開発プロジェクトにてフロントエンドを担当することになりました。
そこで、以下のお達しを受けました。
- ウォーターフォールです
- 1年で200画面、前半フェーズと後半フェーズに分けて作ります
- SPA です
- RESTful API です
- テストでは画面のエビデンスを残すこと
- フロントエンドは Unit Test 書かなくて良い
Unit Test 書きたいです。 200画面も手でテストしたくありません。 だって、 SPA で RESTful API。かなり複雑化する気がします。
あと、意地でも TDD やってみたいんです。 少し前に TDD の本を読んだので完璧にできます。 アンチパターン? 手段が目的? とにかくやってみます。
「TDDで構築後に、エビデンスを撮るために画面からもテストする」みたいなことは絶対にしたくない。
こんなモチベーションですみません。
使用した技術や環境
環境は以下。
- Windows10
- サポート対象ブラウザは Edge のみ
- Visual Studio Code
技術は以下を使用しました。 Selenium 以外は初めて触ります。
やってみた所感
開発初期はとにかくスピードが出ません
開発初期には書いてたコードの半分以上がテストのユーティリティのコードでした。 TDDには程遠い状況で、むしろ逆TDDでした。プロダクトコードを先に書き、それをパスするテストコードを苦労して書き、Greenを維持したままテストコードをリファクタリングする感じです。
テストコードのリファクタリングが功を奏した
開発初期のテストコードのリファクタリングが功を奏して、開発から3か月くらいたったころには大分開発スピードが出るようになり、Seleniumで悩むことも少なくなりました。
開発から6か月たったころには、TDD ができていました。稀に、新しいUIコンポーネントを使うシーンなどで逆TDDをしました。
プロダクトコードのメンテナビリティ
以下の観点で、メンテナビリティを高めることができたかと思います。
- リファクタリングが容易
- 障害対応が容易
最も外側からテストするので、リファクタリングは容易です。 ただ、リファクタリングを一生懸命やる人、やらない人がいるので、ムラがあったかと思います・・・。
障害が発生した場合は、問題となる操作やデータでのテストを追加し、プロダクトコードを改善します。リグレッションテストも素早く終わるので、障害対応は容易でした。
私個人の感想としては、とてもメンテナンスしやすかったです。
メンテナビリティと言えば可読性ですが、このテストによって直接的に可読性が高まるということはありませんでした。リファクタリングがしやすいので、副次的に可読性が高まるということはあり得るかと思います。(そうなったつもり)
テストの実行に時間がかかる
アンチパターンですね。
1画面に掛かるテストの実行時間は、完成したテストで平均で3分くらいです。
特にバリデーションのテストの実行に時間がかかるので、テストコードをコメントアウトしたりして、実行時間を短くします。そういうことをすると、リグレッションを起こすことが稀~にありました。さすがアンチパターン!
また、TDDで流すテストスイートは、現在構築中の画面のもののみか、影響が出そうな画面のみです。また、全画面のテストを一気に流すことはできません。全画面やろうと思うと開発中期で100画面ほどあって1画面3分でも・・・5時間ですか。さすがアンチパターン!
これだけ実行時間が長いと、一歩がデカくなります。さすがアンチパターン!
そして、私が編み出した技が・・・
調子が良いときはこの技がキマります。常に私のターンです。 テストの実行完了を待つ必要はありませんね。
たまにコケます。
結合テスト以降の障害
結合テスト以降は我々の手を離れ、テストされていきます。 数件障害が上がったものの、品質の良さは評価されました。
数件上がった障害は、開発初期に構築した複雑な画面で、テストが不足していること、新しい技術 ( Vue ) に慣れていなかったことが要因です。
また、開発初期の画面はテストコードが否メンテナブルで、修正は少しだけ手間でした。それでも、2~3時間で再現テスト実装/修正/テスト&リグレッションテスト&エビ撮り/リリースができるので、やはり自動化は正義だなと思いました。
テストの粒度
以下のような2つの観点での粒度についてです。
- 画面の状態の粒度
- 画面の状態一つに対するチェックの粒度
例えば下記の図で言うと、1つの画面に対し3つの状態をテストし、チェックはそれぞれ「テキスト」と「ボタン」に対して行います。(図が適当すぎるごめんなさい)
画面の状態の粒度
実装する人によりますが、必要な分だけテストします。不安なところは実施し、不要になったら削ることもしました。多いと時間がかかります。さすが・・・
画面によっては、テストのテンプレートとなるクラスを用意し、それを使うことで実装者による粒度のムラが発生しないようにしたりもしました。まぁ、そんなことができる画面はそもそも共通コンポーネントで作ってたりしますね・・・。
画面の状態一つに対するチェックの粒度
充実した粒度で、メンバー間で統一することができました。 ユーティリティを充実させたことで、例えば以下のようなチェックを簡単に行えました。
- テキストボックスのチェック
- 表示されていること
- 活性状態であること
- Readonlyでないこと
- 値が空であること
こう見るとまぁ・・・オーバーキルかも・・・。 ですが、必要なチェックではあります。 大量のチェックを素早く正確に行えるのは有用でしょう。素早く・・・(白目)
ドメイン知識を得られるか?
TDDの利点として、ドメイン知識を得るきっかけになり得るというものがあるそうです。今回のテスト対象はUIです。UIのドメイン知識とはなんでしょう・・・。例えば以下のようなものでしょうか。
コンポーネントについての知識
コンポーネントについての知識は得にくいと感じました。特定のコンポーネントを意識したテストを書けば、そのコンポーネントの知識を得ることは可能かと思います。とは言え、神クラスを部分的にテストしているような感覚があるかもしれません・・・。
画面のデザインや要素の構成
デザインをテストするのは、SnapShotとかでできるんでしたっけ。データとか、ブラウザのウィンドウの大きさとかで左右されそうで怖かったのでやりませんでした。それに、ここで頑張らなくてもリリースまでの間に多くの人の目に触れてフィードバックが来るでしょうから、それで良いようにも思いました。
要素の構成は、やろうと思えばできるでしょうが、重要ではないですし変わりやすいところです。テストに組み込むべきではないでしょう。
画面の要素の細かな挙動
これについての知識は多く得られます。当然ですかね。
APIのリクエスト/レスポンス
何らかの操作をした際正しいリクエストを投げているのか、そのレスポンスを受けて画面がどう変わるのかを細かくテストしました。
この活動によって、API の仕様についてかなり細かく確認し、考察し、実装前や結合テスト前でも多くの問題を見つけ修正することに繋がったと思います。それがテストのおかげなのかというと、100%そうではないですが。
画面遷移
当然チェックします。 話はそれますが、「戻る・進む」の試験も実装しました。これはバグが出やすいんですよね。
所感まとめ
感覚でしかないのですが5点満点で表現すると以下の感覚です。 テストコードやテスト環境などの改善を繰り返していましたので、開発初期(最初の3か月くらい)よりも開発後期のほうが良くなっていて、その差が大きいので別で評価しています。
テストの粒度は品質と相関関係にあるわけではないですが、後期ですごく多くなったので特徴的な性質として載せています。
開発初期に悪かったことをどうやって乗り越えたか
ここからは具体的かつ技術的な話になります。
開発初期には「開発速度」と「テストコードのメンテナビリティ」の面が非常に悪かったです。とにかくそれを試行錯誤しながら改善しました。最終的な我々の「やり方」を記載します。
Seleniumをラッピング
- 仮想テキストボックスクラス
- 仮想ボタンクラス
- etc
上記のような画面要素操作クラスを作り、Seleniumを直接触らずともブラウザを操作可能としました。 こんな感じで使えるので、直観的に要素を操作する処理が書けます。
const idTextbox = new VirtualTextbox({id:'#id', name:'IDテキストボックス'}); await idTextbox.setValue('hori-chan'); await idTextbox.clear(); await idTextbox.isVisible(); await idTextbox.isEnable();
チェック処理を共通化
画面の要素のチェック処理や、画面のURL、APIのURL / query / body などのチェック全てを共通化しました。
例えば以下のように書くと、idTextboxCheckDefiner.defineTests();
でテキストボックスのテストを展開します。
// jest です describe('画面表示時のテスト', () => { beforeAll(async () => { // 画面表示処理 }); const idTextbox = new VirtualTextbox({id:'#textbox-id', name:'IDテキストボックス'}); const idTextboxChecks = TextboxChecks.create({ element: idTextbox, isVisible: true, isEnable: true, isReadonly: false, value: '', }); const loginButton = new VirtualButton({id:'#button-login', name:'ログインボタン'}); const loginButtonChecks = ButtonChecks.create({ element: loginButton, isVisible: true, isEnable: false, label: 'ログイン', }); // テキストボックスとログインボタンのテストを展開 new CheckDefiner().add(idTextboxChecks, loginButtonChecks).defineChecks(); });
上記で展開されるテストはこんな感じになります。
- 画面表示時のチェック
- IDテキストボックスのチェック
- 表示されていること
- 活性状態であること
- Readonlyでないこと
- 値が空であること
- ログインボタンのチェック
- 表示されていること
- 非活性状態であること
- テキストが'ログイン'であること
- IDテキストボックスのチェック
上記で言うTextboxChecks
クラスやButtonChecks
クラスは、それぞれ jest のdescribe
メソッドにて「IDテキストボックスのチェック」や「ログインボタンのチェック」を展開します。
それぞれのChecksクラスは、内部的に以下のようなクラスを持っており・・・
- VisibilityCheck
- EnableCheck
- ReadonlyCheck
- ValueCheck
それぞれのCheckクラスが、jestのtest
メソッドを実行し、その中でexpect(await virtualElement.isVisible()).toBeTruthy()
みたいなことをしています。
1画面のテストにつき定義するクラスの構成を統一
.spec.ts ファイルにはテストの概要部分を定義し、細かな部分は以下のクラスのインスタンスに処理を移譲しました。
- XxxViewCheckDefiners
- .spec.ts から参照される
- 「画面表示時」や「ログインボタン押下時」などの単位で、画面のURLや各要素やAPIなどの Check をまとめて生成して返却するメソッドを持つ。
- Check は XxxViewChecks から取得し、デフォルトのチェック内容から変更がある場合は、その項目のみ再指定する。
- XxxViewChecks
- XxxViewCheckDefiners から参照される。
- 画面のURLや各要素やAPIなどの Check を一つ一つ定義し、デフォルトのチェック内容を設定する。
- XxxViewOperations
- .spec.ts から参照される
- テスト対象画面への遷移操作や、画面の要素への値の入力、次の画面へ遷移するボタンの操作を行うメソッドを定義する。
- XxxViewParts
こんな関係です。
specファイルはこんな感じになります。
describe('Login画面', ()=> { describe('画面表示時', ()=> { const parts = LoginViewParts.create(); const definers = LoginViewCheckDefiners.create(parts); const operations = LoginViewOperations.create(parts); operations.goToMyPage(); definers.whenViewed.defineTests(); }); describe('ログイン成功時', ()=> { const parts = LoginViewParts.createForSuccess(); const definers = LoginViewCheckDefiners.create(parts); const operations = LoginViewOperations.create(parts); operations.goToMyPage().login(); definers.whenLoginSuccessful.defineTests(); }); describe('ログイン失敗時', ()=> { const parts = LoginViewParts.createForFailure(); const definers = LoginViewCheckDefiners.create(parts); const operations = LoginViewOperations.create(parts); operations.goToMyPage().login({nextUrl: parts.URL_LOGIN}); definers.whenLoginFailure.defineTests(); }); });
parts生成時にユーザー情報を指定したりして、操作で入力する値やチェック定義における内容が少し変わります。変えすぎると破滅します。
エビデンスは Markdown で出力
画面のエビデンスを撮れと言われていました。そこで、エビデンスを Markdown で出力する仕組みを作りました。あと、上の項まででSpecファイルの可読性は高まりましたが仕様が読み取りにくかったので、エビデンスを充実させてその代わりとすることも目的でした。
ただし、テストコードにはなるべくエビデンスを扱う処理を書きたくありません。そこで、以下の施策をとりました。
- Jest の describe や test メソッドに渡してるテスト名みたいなのを取得してエビデンスに使用する
- 画面キャプチャは operations などで何かしらの操作を行う前後に自動で撮る(キャプチャ要否を boolean で指定するくらいは我慢した)
- specファイルごとにmdファイルを出力する
- 目次を作る
- 失敗したテストには赤字で警告を表示する
- etc
jest のテスト名みたいなのは、jest からは上手く取得することができなかったので、結局 jest の関数を wrap した関数($describe
とか$test
とか)を作って使用することとし、そこでテスト名を受け取り収集するなどの工夫をしました。
これにより、画面の仕様がエビデンスに出力されることとなりました。
json-server のテストデータがデリケートで困る
画面を動かす都合上どうしてもデータに依存したテストになってしまいます。しかし、データは開発が進むにつれてどんどん追加・変更され、最悪既存のテストが動かなくなります。 そこで、json-server がデータとして読み込むjsonファイルから動的にデータを取得し、テストの期待値として使用することとしました。
動的にデータを取得しテストの期待値として使用する
その代わり、テストコード側でのデータ取得時に、そのデータがそのテストでの目的を果たせるデータであるかどうかをチェックします。チェックした結果、テストデータとして不適切なものであれば、エラーをスローしてテストを失敗させます。
「テストコードにおいて期待値はべた書きすべき」 というのが通説かと思います。 ですが我々のテストにおいては、データの取得経路が全く異なるので品質を担保できると判断しました。
開発初期に悪かったことをどうやって乗り越えたかまとめ
もっと色々とやったことはありますが、特に上記の対策でうまくいきました。
以下にこのやり方をして良かったこと・悪かったことを記載します。
良かったこと
- specファイルを可読性が高い状態に保てた
- エビデンスの統一感が良い感じ、そして見やすい
- チェック内容や結果の文言が統一できた
- エビデンスのフォーマットが統一できた
- チェックの粒度を統一できた
- Seleniumのコツを実装者が覚えなくて良い
- TDDできるほどにテストの定義が容易
悪かったこと
- テストユーティリティの作成に工数がかかる(1年のうち3か月くらい使ったかも)
- テスト実行に時間が掛かる
- 実装者によってメンテナビリティにムラが出る
頑張ったんですけどね、メンテナビリティが高いコードを書くことを強制するというのは、難しいです。
総まとめ:大成功
大成功でした!やって良かったと思ってます。アンチパターンと言われている割には有用だったと思いました。長い実行時間に次にやるべきことを考えたり、1歩が大きくてもなんとかなります。今後メンテナンスでどう活かされるのかも体験しておきたかったですが、私は別のプロジェクトに移ってしまいました。残念。
そして、成功でしたがもうやらないと思います。
やっぱり遅いです。サクサクやれたほうが楽しいと思います。画面のエビデンスを細かく撮る必要がある場合はやるかもです。
長文駄文にお付き合いいただきありがとうございました。
おまけ
json-server で色々やった話
テスト用のMockサーバーとして json-server を使用していました。 json-server は、jsonファイルでデータを用意しておき、シンプルにそのデータのCRUDをRESTfulに実行するのが基本動作ですが、設定を変更して色々と挙動を変更しました。
例えば以下のような細工をしていました。
受けたリクエストをファイルに吐き出す
テストの中でファイルに吐き出したリクエスト参照し、想定通りのリクエストを投げているのかをチェック可能にしました。
好きなタイミングで好きなレスポンスを返す
jest から json-server に「次のレスポンスはこのステータスでこのBODYを返してください」とリクエストを投げておき、次にブラウザからリクエストを行った際にそのレスポンスを返却させるようにしました。HTTPステータスはテストしにくい部分ですが、とてもやりやすかったです。
テスト対象の画面にたどり着くまでにいくつも画面を経由する場合の書き方
結構これで、困る人が多いのではないかな~と思ってます。
いくつも画面を経由する場合、その途中の画面の仕様が変わったりすると、後続の画面まで影響が出かねません。
そこで、上述の「1画面のテストにつき定義するクラスの構成を統一」で紹介したOperationクラスを活用します。
前の画面の Parts と Operation のインスタンスをテスト対象画面の Operation インスタンスが保持し、テスト対象画面の Operation の goToMyPage()
メソッド(次画面へ遷移する処理)の中で、前の画面の Operation の goToMyPage()
メソッドを呼ぶようにしました。そうすることで、画面遷移処理が DRY となりメンテナンスしやすくなります。
まぁ、それだけです。