RSpecでRequest Describer
WebアプリケーションのHTTPレベルでの振る舞いに対してテストを記述するとき、皆さんはどのような考えを持ってテストコードを記述しているでしょうか。この投稿では、この手のrequest-specと呼ばれるテストについて考えながら、テストを書くときの幾つかの方針と、RSpec::RequestDescriberを利用してテストコードを簡略化する方法を紹介します。
request-specとは
request-specという、HTTPにおけるリクエストとレスポンスの組み合わせを、言わばブラックボックスとして扱うテスト形式の呼び名があります。リクエストを入力、レスポンスを出力として扱い、ある入力に対して期待される出力が返されるかどうかをテストします。rspec-railsの中では、request-specに対して以下の説明が与えられています。
Use request specs to specify one or more request/response cycles from end to end using a black box approach. -- via https://github.com/rspec/rspec-rails
rspec-railsはRuby on Rails用のライブラリですが、Rackアプリケーション用にrspec-railsと同じような機能を提供する、rack-testというライブラリも存在します。他の言語においても、同様のライブラリが散見されます。
GET /usersのテスト例
テストの例として、仮に GET /users
というリクエストを送る場合を考えてみましょう。このとき、例えばレスポンスのステータスコードが200であり、レスポンスボディにユーザIDが幾つか含まれていることをテストするような例が考えられます。rspec-railsやrack-specでは、リクエストにヘッダ、パス、ボディを指定できます。またレスポンスからは、ステータスコード、ヘッダ、ボディを参照できます。一般的なHTTPリクエストとHTTPレスポンスで表現可能なデータを反映していると言えるでしょう。
describe "GET /users" do
it "returns 200 with user IDs" do
get "/users"
response.status.should == 200
response.body.should include("ユーザID")
end
end
POST /usersのテスト例
リクエストによってシステムの内部状態が変更される場合もあるでしょう。その場合は、システムの内部状態を何らかの方法でテストしても良いでしょう。例えば、POST /users
というリクエストを送信し、データベースの中にユーザのレコードが新しく生成されているかどうかを確認する、というテストが考えられます。
describe "POST /users" do
it "creates a new user and returns 201" do
post "/users", name: "alice"
response.status.should == 201
User.count.should == 1
end
end
テストをどう分割すると良いのか
RSpecではitメソッドを利用したブロックごとにテストケースが分割されることになりますが、1つのテストケースの中にどういったコードを記述するかは完全に書き手に委ねられています。例えば、1つのテストケースの中に「GET /usersにリクエストを送って結果を確かめる」というコードと「POST /usersにリクエストを送って結果を確かめる」というコードの両方を含めても動作します。極端に言えば、全てのテストコードを1つの巨大なテストケースに詰め込んでも良いわけです。では、一体どのような判断基準でテストを分割すると良いのでしょうか。
it "returns users and creates a new user" do
get "/users"
response.status.should == 200
post "/users", name: "alice"
response.status.should == 201
end
テストケースを小さく保つ
テストケースには「検査が失敗すると直ちにそこで処理が中断される」という性質があります。そのため、1つのテストケースに多くの検査を詰め込みすぎてしまうと、検査が失敗した場合の原因特定がより困難になると言えます。例えば上記のテストコードで should == 200
の検査が失敗した場合、そこで処理は中断されてしまいます。この状況からでは、POST /users
はこのとき成功する状態にあるのか、それとも両方とも失敗する状態にあるのか、という情報が得られません。全体に問題があるのか一部にのみ問題があるのかということが分からなければ、問題の原因特定が難しくなってしまいます。
テストが失敗した場合の原因特定を助けるために、テストケースは出来る限り細かく分ける方が良いでしょう。テストについてよく語られる慣習の中に、1つのテストケースには1つの検査を対応させようという基準があります。確かにこの状態よりテストケースを小さくすることはできそうにはないですし、非常に理解しやすく、また信頼しやすい基準だと言えます。しかし現実的には、全てのテストケースに1つの検査しか含まないようにしようとすると、実装コストと要求仕様との連続性や実行効率の面で問題になることがあります。そのため、何らかの条件付きで複数の検査を行うことを許容するのが妥当でしょう。
テストケースの粒度を揃える
同一テストケース内での複数の検査を許容する代わりに、テストケースを肥大化させる圧力を抑える何らかの方針が必要です。そこで「テストケースの粒度を揃える」という方針を設けるのはどうでしょうか。あるテストケースにコードを追加しようとするとき、他のテストケースと比べて粒度が変わってしまうようであれば適切に分割しましょう、というものです。
これだけでは定性的に過ぎるため、実際にはテストのレイヤに応じてこれに則ったより具体的なルールを定めるのが良いと思います。例えばrequest-specでは、1つのテストケース内では必ず1つのリクエストしか発行しない、というルールが考えられます。request-specは本質的に必ず1つ以上のリクエストを含んでいるので、この方針を守ることはそこまで難しくないと考えます。「テストケースの粒度を揃える」というのは実際にはレイヤごとに適切な粒度を決めて揃えようという話なので、「同じレイヤでは同じ粒度のテストケースに揃える」という表現の方がより適切かもしれません。
例えばRequest Describerを使う
RSpec::RequestDescriberという、request-spec用のライブラリがあります。これは前述したような「1つのテストケースで1つのリクエストを送る」というパターンのテストコードの記述を支援するためのライブラリです。describeメソッドを利用して記述した文章を元にHTTPリクエストを送ってくれる、というのがこのライブラリの核となる機能です。
# describeで記述されている文字列の内容が解析されて、
# subject { get "/users/#{id}" } というコードと同等の内容が自動的に定義されます
describe "GET /users/:id" do
let(:id) do
User.create(name: "alice").id
end
context "with invalid ID" do
let(:id) do
"invalid"
end
# rspec-railsではリクエストを送るメソッドがstatus codeを返すので、
# (shouldのレシーバを省略することで) subjectの実行結果を呼び出してテストしています
it { should == 404 }
end
# 最後に実行したリクエストに対するレスポンスが response から参照できるので、
# subjectを評価したあと、レスポンスボディの内容をテストしています
context "with valid ID" do
it "returns a user" do
should == 200
response.body.should include("alice")
end
end
end
request-specの記述にRSpec::RequestDescriberを使う大きな利点として、リクエストの実行が自動的に1度だけ行われるため、全てのテストケースの粒度が統一されるという点が挙げられます。また、describeを強制されることから、エンドポイントごとにテストケースがまとめられたり、テストの出力するレポートが幾らか統一されるという効果が期待されます。他に、HTTPリクエストを組み立てるのに必要になるであろうparamsやheadersといった機能が元から用意されているため、テストのコード量が削減されたり、共通する部品を再利用する機会がより生まれやすくだろうということも期待されます。
おわりに
この投稿では、request-specと呼ばれるテストについて考察しながら、テストを書くときの幾つかの方針と、RSpec::RequestDescriberを利用してテストコードの記述を簡略化する方法を紹介しました。
このライブラリは元々、テストケースをたくさん記述するために業務上で必要に迫られて用意したものですが、訳あってラーメンを食べるかOSSに貢献するかどちらかを強制されることになり、OSSとして公開するはこびとなりました。ライブラリの実装自体は60行程度のもので大した内容ではありませんが、何かの役に立てば幸いです。