前回、IntegrationテストをTurnipで導入すると良いという話をしましたが、それ以前は長い間Seleniumを利用してE2Eテストを行ってきました。Seleniumは実際にブラウザを自動的に操作して評価するわけですからユーザー視点での評価としてはこれ以上ありません。しかし、実際に運用してみると迅速にリリースを進めたいという思惑から遠のいていくケースも少なくありません。
現在Ruby on RailsでのWebアプリケーションを運用中ですが、テスト手法は受け入れテストも兼ねる機能テストをCucumber + Capybara + rack_testにて、単体テストをRSpecでリリースフローを回しています。今まで自分が構築してきたWebアプリケーションのテストについて振りかえってみて、どうしてこうなったという辺りを紹介してみたいと思います。
目次
衝撃的だったSelenium
自分がSeleniumに出会ったのは2008年頃で、その当時テストというとエクセル表に手順を記載したものをテスターに依頼してました。当時はFirefoxの拡張機能でSelenium IDEがあることを知り、テストケースがHTMLで書けて更に勝手に実行されて評価結果がGreen or Redで表示される事に衝撃を受けました。
その後、そりゃ手動でテストするより断然良いよねということで徐々にSelenium IDEでテストするようになりました(Javaの方は使ったこと無い)。エクセル表にテストケースを記載してVBAでHTMLを自動生成するなんてこともやってました (まだ当時の記事が残ってました: Excelマクロによる、seleniumテストケースの自動生成(1):CodeZine(コードジン))
この頃苦労したのはFirefoxとSelenium IDEのバージョンが合わないと上手く動作しなくて困ったり、Selenium IDEは自分でテストケースを毎回開いて実行する必要があるので実質画面に張り付いていなきゃいけなくて面倒でした。しかし、それでも自動化の第一歩だったかと思います。
- 受け入れテスト : Selenium IDE
- 単体テスト : TestUnit + shoulda
CapybaraによってRubyで直感的にSeleniumが書けるようになった
Selenium IDEは数年間使ってました。その後、Rails 3系スタートでの新しいプロジェクトが立ち上がったこともあってテストのやり方を見直すタイミングがありました。この頃はもうSelenium IDEは廃れて来ていてFirefoxがもう全然バージョン上げられない状態になっていたのと、テスターでは無くWebアプリケーション開発者自身でテスト仕様記述から評価まで行おうという風潮になっていました。
当然Railsで開発しているわけですからRubyで書けるのが一番良いことで、今までSelenium IDEで書いていたようなテスト手順はCapybaraによって置き換えることが出来ました。以下はcucumberのstepsとcapybaraの例です:
もし /^"(.+)"ページを表示する$/ do |page_name|
visit path_to(page_name)
end
もし /^"(.+)"リンクをクリックする$/ do |link|
click_link(link)
end
ならば /^"(.+)"と表示されていること$/ do |text|
assert page.has_content?(text)
end
Railsで作るWebサービスはAPIとWebUIを用意することになっていたので、受け入れテストはAPIがCucumberからHTTPをlocalhostに素で叩くものとWebUIはCapybaraからとなりました。
- 受け入れテスト (API) : Cucumber
- 受け入れテスト (WebUI) : Cucumber + Capybara + Selenium
- BDDテスト : RSpec (controllerテスト、modelテストをよく使った)
受け入れ駆動開発としてE2Eテストを組み込んだのはユーザー視点でとても良かったが・・・
受け入れテストファーストで記述するアプローチ自体はとても良くて、価値仮説をそのままfeatureファイルに記述してユーザーにとってなにが大切なのかをしっかりと考えてから開発することが出来てとても有意義でした。
テストケースが記述されていてリグレッションテストが出来るのはシステムとしてはソースを改変してもデグレが起きにくいなど安心して進められたので、とにかくほぼ全ての画面・挙動にバリバリテストを追加していきました。その結果は・・・
重い・・・
テストケースを書けば書くほど実行時間が長くなり、10分、15分とどんどん増えていきました。最終的には40分くらい掛かっていたと思います。
こりゃいかんということで、次に対策したのは、E2Eだとやはり通信コストがリモートであってもローカルであってもバカ高いと云うことで、インテグレーションテストをrack-test + capybara + capybara-webkitでJSテストでありながらTCPを挟まずにrackレイヤーからリクエストはハンドリングする様にしたことで受け入れテストで記述していたfeatureとstepsの方は大きく改変を加えずに移行することが出来ました。
また、E2Eテストの場合はユーザー操作と同じですからユーザー登録やサインインが必要な場合等、テストの度にその手順を毎回最初から順を追って踏まないといけなくなります。この点がとてもコストが掛かっていました。インテグレーションテストではこのようなテストの場合でも、事前条件としてセッションとDBを直接設定した状態でテストが開始でき、且つ、この両端だけを弄ることで開発ターゲットはモックせずにE2Eテストとほぼ同等の挙動をテストすることが出来る様になりました。セッション操作はrack_session_access、DB操作はFixture ReplacementであるFabricationを採用しました。以下はサインイン状態を作成するときのstepコードです:
前提 /^サインインしていること$/ do
@signin_user = Fabricate :user
page.set_rack_session user_id: @signin_user.id
end
スマートに書けてるでしょう?もう登録画面を開いて登録して、テストの度にサインイン画面にリクエストを送る必要なありません。インテグレーションテストを導入していくうちに、RSpecで記載していたcontroller/helper/routes/viewテストは殆ど記述する必要が無くなりました。インテグレーションで記載すれば良いし、エラーケースなど通りにくい部分がある場合はモックすれば良い。実際の殆どの振る舞いを記述して実行してみてもhelperメソッドが通らなかったといったことがあった場合は使って無く必要無かったなんてことも発見できて良い事づくめでした。
但し、modelテストだけはとても重要で、DB破壊を起こさないようにDBマイグレーションを行う場合は真っ先に記述するように心がけました。DBだけは何かあると手遅れになりますからね・・・。サービスの種類にもよりますが、金融系など情報の表示自体にシビアな意味をもつもので無ければフロントエンド寄りになれば何か表示がずれるなどのバグあっても「修正すれば良い」で済みます。RSpecでは後はロジックが複雑な実装を行った場合のクラス等単体テスト寄りのコードを記述する際に重点を置いて利用するようになっていきました。
capybara-webkitに関しては高速化の観点ではheadlessになって良かったのですがQtをインストールする必要があって使いにくかったので導入後早い段階でpoltergeist(PhantomJSのcapybaraドライバ)に差し替えました。こちらもQtは使っていますがスタンドアロンなのでphantomjsバイナリをパスの通っている場所に配置しておくだけでOKです。
- 受け入れテスト (API) : Cucumber
- 受け入れテスト (WebUI) : Cucumber + Capybara + Selenium
- インテグレーションテスト (WebUI) : Cucumber + Capybara + rack-test + rack_session_access + Fabrication + poltergeist
- BDDテスト : RSpec (modelテストをよく使った)
膨大化する実行時間とオオカミ少年化する不安定なJSテスト
E2Eテストは時間が掛かるといいつつも、実際のユーザー操作と全く同じ環境で品質保証出来るのでAPIとOAuth等外部サービスとの連携を行う部分などアクセス出来ない等の致命的なエラーを起こさない為のテストケースはかなりの部分で残していました。CIでAPIとWebUIはPC向けとモバイル向けの画面(レスポンジブでは無くタッチ最適化でHTML/CSS/JSを切り分けていた)を別ジョブにして同時実行などで実行時間を何とか抑えていました。
何とか運用可能なように努力していたものの、以下の様な不具合がCapybara + Seleniumにはどうしても発生しており(2016年半ば頃時点)、対策すれどもすれどもテスト実行が赤が出続ける状況が続きました:
- cookieが拾えない・設定出来ないことがある
- 要素が拾えなくてタイムアウトが起こる(特にJSでのアニメーションを挟んだとき)
- クリックしてもダイアログが開かないなどJSイベントトリガーが発火しない
- マルチドメインを挟んだ画面遷移を行うとセッションが正しく保持出来ない
- 同一OS上で何度もブラウザ起動を繰り返していると応答が無くなる
幾ら受け入れテストが仕組みとしては良いとしても、ほぼ毎日のようにSeleniumを含むテストドライバ起因によるテスト失敗が出たのでは本当のバグが出た場合と区別が付かなくなるので、リグレッションテストを行う意味が無くなってしまいました。
インタラクションのテスト自動化を捨ててnon-JSインテグレーションテストへ
そこで、Seleniumに別れを告げ、インテグレーションテストに全てのE2Eテストケースを移行することを決断しました。移行作業自体は数日程度で完了したのですが、今度はportergeistでPhantomJSが頻繁にクラッシュするようになるし、rack_testといえど、JSテストになるのでHTML描写後もJSスクリプトロード・初期化を行う必要があって結局は実行に10分以上かかるようになってしまいました。
ここで一旦自分達のいま取り扱っているプロジェクトの構造を振りかえってみると、サービスに長期滞在するような性質のものでも無いということもあってReactやAngularは導入せず(といっても数年前なのであまりホットになっていなかった。node.jsのnon-blocking I/Oが話題になってた)、erbでHTMLを形成するオーソドックスなViewを利用しており、JSはというとダイアログを開いたりするようなウィジェットやアニメーション効果のために使っていて重点を置いて自動テストで評価すべきなのはJSでは無いので、テスト対象から外した方が安定性、速度面で有利だという判断をしました。
portergeistをjavasctipt_driverから外し、素のrack_testでHTMLのみを評価するようにしました。その際、いくつかの画面ではaタグでは無くクリックしても次のページに遷移しない部分であったり、ボタンだけどsubmitでは無いフォーム等で特定のformをsubmitしていたので次のリクエストに繋げる部分のみ、stepを修正する必要がありました:
features/support/env.rb
class Capybara::Driver::Node
def submit_form!
raise NotImplementedError
end
end
class Capybara::RackTest::Node
def submit_form!
Capybara::RackTest::Form.new(driver, self.native).submit({})
end
end
class Capybara::Node::Element
def submit_form!
base.submit_form!
end
end
features/step_definitions/steps.rb
もし(/^"(.*?)"をsubmitする$/) do |form_name|
find(form_name).submit_form!
end
- インテグレーションテスト (API) : Cucumber + rack-test + Fabrication
- インテグレーションテスト (WebUI) : Cucumber + Capybara +rack-test + rack_session_access + Fabrication
- BDDテスト : RSpec (modelテストをよく使った)
これで漸く、テスト時間も2分程度となり、コミット前に開発者がチェック出来るコストとなり、何かFが出た場合は自分の修正が別のコードに影響したということが検知出来たり、バグを入れこんだ等が容易に分かる様になって開発フローを安定させることができました。
実際rack_testだけで運用してみて問題無かったのか振りかえってみる
で、結局のところ、E2Eをrack_testに全て移行して何か問題が出たのか?ということの回答としては、
Rails使ってるならE2Eは百害あって一利無し。すぐにインテグレーションテストに置き換えた方が良い
と断言できます。
non-JSテストにしたのはどのようにViewを使っているかにも依るので一概に廃止した方が良いとはいえませんが、今回のプロジェクトの場合は開発時にちゃんと動作するかどうかを軽くテストしておけば問題無いだろうという程度の軽い運用で特に致命的な問題は起こりませんでした。
E2Eからrack_testに変えたことでの差異によってチェック出来なくなったバグは無かったのかという点では、rackはミドルフレームワークで、実際のTCPパケットを生成してリクエスト出来ないのでその点でrackサーバーやRailsでのディスパッチ〜コントローラアクション呼び出し部分で想定していないリクエストが来ると不具合が本番環境で検出されることもありました。以下の様なものです:
- URLにRailsでは生成出来ないエンコードなどでリクエストしてくると50xエラー
- WebDAVのメソッドを受け取って50xエラー
- passengerで特定のパケットを受け取ると50xエラー
- actionに辿り付く前に特定の例外が出るとHTMLが全く無い状態でRailsがレスポンスしてしまう
- 壊れたsessionをcookieに設定しておくと50xエラー
- ...
E2E〜rack_testの境界で起こるエラーは結局は見ての通り、リクエストパケット発生〜Railsに辿り付いて開発ターゲットに辿り付くまでに起こるRackサーバやRailsの未実装、バグが殆どで、その部分をIssueを上げればOSSの貢献にはなると思いますが、コスト掛けてテストしても自分達のプロジェクトにはあまり貢献しないと思います。
今後のRailsアプリケーション開発時にどうやってテスト設計していくか?
2018年1月現在ではRailsのバージョンは5.1.4、5.2がβ版でリリースされています。最近ホットなのはVueやAngular、React等でViewの生成、DOM操作にあたるフロントエンド部分を記述し、WebAPIを提供するController/Modelをバックエンド部分としてなるべく分離して開発したいというプロジェクトが増えてくると予想されます。
そうなってくると、フロントエンドとバックエンドのI/F境界はサーバ側ではHTMLレンダリングを行わずサービス提供はJSON等のWebAPIでバックエンドから提供、フロントエンド側をJS/CSS/HTMLで全て記述するようになるかと思います。
今回説明したインテグレーションテストに関しては、WebUIにあたる部分がサーバーサイドでは無くなってしまうので、WebAPI以降のバックエンド部分を引き続きインテグレーションテストと単体テストで行いたいと計画すると思います。今のタイミングでスタートアップするなら、CucumberはRSpecと統一したいと思うので、
- Turnip + rack_test + RSpec + Fabrication(Factory Bot)
でテスト設計すると思います。
フロントエンド側は実際に試す機会がまだ無いのでどういったテスト方法がおすすめなのかは分からないのですが、少なくとも、WebAPIを末端としてモックし、フロントエンドのみでインテグレーションテストが行えるように出来ないか、または、そもそもフロントエンドテストするリスクがあるか?、や、フロントエンドはnode.jsにしてRailsから完全に分離するか?から検討すると思います。今後の課題としたいと思います。