9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

リーダブルRSpec(カスタムマッチャー+RSpec::ContextHelper編)

Last updated at Posted at 2022-07-28

読みやすいRSpec書いていますか?


読みやすいRSpecって何?

「簡潔でテストケースを見てテスト内容が理解しやすいもの」と考えています
ここでのテストケースとは、it/exampleブロックのことを指しています


例題

例えば、Rails製REST APIのRequest Specを書くことを考えてみます
コードは正しいか確認していないので、多少間違っていてもお許しください🙇

前提

  • APIはたくさんある
  • 良くないけど、クエリパラメータのチェックをcontrollerでしているため、テストはRequest Specで行う

APIの共通仕様

  • Server Errorを除き、HTTPステータスコードは必ず200で返す
  • HTTPレスポンスボディはJSON形式で返す
    • 成功時のレスポンス
      { "status": 200, "message": "Succeed", "data": ... }
      
    • 未認証時のレスポンス
      { "status": 401, "message": "Unauthorized" }
      
    • リクエストパラメータが不正な時のレスポンス
      { "status": 400, "message": "Bad Request", "errors": ["error", ...] }
      

ユーザー一覧APIのSpec

/users?page=1のようにアクセスすると、ユーザー一覧を返すAPIのテストを書いていきます

まず、APIへのリクエストを書いて

before do
  get "/users", params: { page: page }
end
let(:page) { 1 }

単純に書くと

it "respond with a 401 response" do
  expect(response).to have_http_status 200
  expect(response.content_type).to eq "application/json"
  expect(response.body).to be_json include(
    "status" => 401,
    "message" => "Unauthorized",
  )
end

context "when logged in" do
  include_context "logged in" # ログイン状態にするshared_contextを定義済み

  context "when page is 1" do
    let(:page) { 1 }

    it "respond with a 200 response" do
      expect(response).to have_http_status 200
      expect(response.content_type).to eq "application/json"
      expect(response.body).to be_json include(
        "status" => 200,
        "message" => "Succeed",
        "data" => ..., # 省略
      )
    end
  end

  context "when page is 'a'" do
    let(:page) { "a" }

    it "respond with a 400 response" do
      expect(response).to have_http_status 200
      expect(response.content_type).to eq "application/json"
      expect(response.body).to be_json include(
        "status" => 400,
        "message" => "Bad Request",
        "errors" => ["page must be an integer"],
      )
    end
  end
end

問題点
  • 簡潔じゃない

Shared examplesを使って書くと

shared_examples "respond with an API response" do |status, message, data: nil, errors: nil|
  it "respond with an API response" do
    expect(response).to have_http_status 200
    expect(response.content_type).to eq "application/json"
    expect(response.body).to be_json include(
      "status" => status,
      "message" => message,
      "data" => data,
      "errors" => errors,
    )
  end
end

# テストケースはここから下
it_behaves_like "respond with an API response", 401, "Unauthorized"

context "when logged in" do
  include_context "logged in"

  context "when page is 1" do
    let(:page) { 1 }
    it_behaves_like "respond with an API response", 200, "Succeed", data: ...
  end

  context "when page is 'a'" do
    let(:page) { "a" }
    it_behaves_like "respond with an API response", 400, "Bad Request", errors: ["page must be an integer"]
  end
end

問題点
  • 簡潔じゃない => 解決
  • コンテキストに依存している(it_behaves_likeのコンテキストで動くため)
  • テスト対象が何か分からない(responseを暗黙的に使っているため)

カスタムマッチャーを使って書くと

# be_api_response_ofカスタムマッチャーの定義は長くなるので省略

it { expect(response).to be_api_response_of(401, "Unauthorized") }

context "when logged in" do
  include_context "logged in"

  context "when page is 1" do
    let(:page) { 1 }
    it { expect(response).to be_api_response_of(200, "Succeed").with_data(...) }
  end

  context "when page is 'a'" do
    let(:page) { "a" }
    it { expect(response).to be_api_response_of(400, "Bad Request").with_error("page must be an integer") }
  end
end

問題点
  • 簡潔じゃない => 解決
  • コンテキストに依存している(it_behaves_likeのコンテキストで動くため) => 解決
  • テスト対象が何か分からない(responseを暗黙的に使っているため) => 解決
  • テスト条件がDRY?じゃない

カスタムマッチャーとRSpec::ContextHelperを使って書くと

# be_api_response_ofカスタムマッチャーの定義は長くなるので省略

example { expect(response).to be_api_response_of(401, "Unauthorized") }

context_with _shared: "logged in" do
  example_with(page: 1)   { expect(response).to be_api_response_of(200, "Succeed").with_data(...) }
  example_with(page: "a") { expect(response).to be_api_response_of(400, "Bad Request").with_error("page must be an integer") }
end

問題点
  • 簡潔じゃない => 解決
  • コンテキストに依存している(it_behaves_likeのコンテキストで動くため) => 解決
  • テスト対象が何か分からない(responseを暗黙的に使っているため) => 解決
  • テスト条件がDRY?じゃない => 解決
  • 1行で書くと横に長くなる => いまどき少しは横に長くなってもいいよね?
  • 英文のように読めない => 何かいいアイディアが思いついたら教えて欲しいです🙇

まとめ

  • shared_examplesは読みにくくなるので、あまり使わない方がいい
  • テストケースを見てテスト内容を理解できるカスタムマッチャーが作れる場合は、使った方が読みやすい
    ただし、カスタムマッチャーに対してもテストを書く必要があると思います
    ちなみに、コード例で使っているbe_jsonマッチャーはSaharspecにあります
  • 条件を切り替えつつ行うテストを簡潔に書けるようにRSpec::ContextHelperを作ったのですが、どうでしょうか?
    100行ほどのコードなので自分で好きなように改変するのも簡単だと思います

他に思うこと

  • subject/is_expectedはテスト対象が何か分からないので、読みにくいと思います
  • RSpec::Parameterizedも使われていますが、たいていのケースではRSpec::ContextHelperの方が読みやすくなるのではないかと思っています

読みやすいテストを書いていきたいですね

9
3
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?