介紹 RSpec Request Spec

kevinluo201

Kevin Luo

Posted on September 5, 2021

介紹 RSpec Request Spec

這次想介紹 RSpec 的 Request spec

什麼 Request spec? 為何推薦使用它?

Request spec 故名思義,是專門測 HTTP 請求的測試。一個 web 的應用,其實也可以說是一個用 HTTP request 跟伺服器做互動的程式。
一個合格的 Rails 開發者,我們通常 model 的測試覆蓋率還不錯(是吧?)。不過那只能保證比較不會有寫入錯誤資料進資料庫的事情發生。使用者基本上不會直接去呼叫你的 model 的方法,他們會直接打 request 到你的伺服器。結果我們卻不測這件事好像不是很合理?

好啦,其實有寫 model test 的團隊就謝天謝地了,很常看到開發的團隊會直接跳過 request test 直接做 end-to-end 的人工測試,或者直接讓 QA 來做 end-to-end 的測試。
如果是一個短期的專案,老實說這也不是什麼大問題。
但如果是一個為長期的專案,通常就是指你現在在公司上班都要維護的專案,恐怕常常是程式出錯時,但錯的地方根本不是程式碼修改的地方。其實是因為就算我們的 model test 的覆蓋率高到 100%,也不能保證這件元件互動也是完全沒問題。而有 Request test 就有較高的機會前提發現這些錯誤。

下圖是一個機器運作正常但還是出現意料之外結果的範例:
垃圾車失敗

我覺得寫 Request test 可以幫開發者花少一點時間 debug。

要用 RSpec 的Request spec 或 Controller spec?

RSpec 已經有一個 Controller Spec,就是專門來測 controller 的。那為什麼不用 controller spec 來測 controller 而是用 request?
第一個理由是因為 Request spec 會運行一個 HTTP request 會用到的所有層面,例: routing, views 甚至 rack middleware。而 controller spec 只有單獨測 controller action,除非自己還分別再去寫 routing spec, view spec 那 request spec 寫起來似乎 CP 值較高。

另一個理由就比較單純了,RSpec 開發團隊推薦直接用 Request spec:

For new Rails apps: we don't recommend adding the rails-controller-testing gem to your application. The official recommendation of the Rails team and the RSpec core team is to write request specs instead. Request specs allow you to focus on a single controller action, but unlike controller tests involve the router, the middleware stack, and both rack requests and responses. This adds realism to the test that you are writing, and helps avoid many of the issues that are common in controller specs. In Rails 5, request specs are significantly faster than either request or controller specs were in rails 4, thanks to the work by Eileen Uchitelle of the Rails Committer Team.

Request spec 特點

  • 會執行一個 request 時全棧的程式,包括:會執行 routing,會跑 controller action,會渲染 erb等。
  • 速度快 (跟 capybara 比的話)
  • 可以在一個測試範例中做數個 request,甚至可以跟隨 redirect 到下一頁
it "creates a Widget and redirects to the Widget's page" do
  get "/widgets/new"
  expect(response).to render_template(:new)

  post "/widgets", :params => { :widget => {:name => "My Widget"} }
  expect(response).to redirect_to(assigns(:widget))
  follow_redirect!
  expect(response).to render_template(:show)
  expect(response.body).to include("Widget was successfully created.")
end
Enter fullscreen mode Exit fullscreen mode

什麼時候不適合用 Request spec

  • 因為 Request spec 不會執行任何 Javascript,所以如果你的畫面是用 vue, react 渲染,又想確定指定的元素是否有被渲染出來時,就不適合用
  • 同上,如果目標是想看畫面上的使用者互動,因為那些互動也都是 Javascript,所以也不行。(但開發者應該是要為 js 打的 API寫 request test)

Capybara 那種自動的 end-to-end 測試不在這次的討論範圍內~
不過如果是一般的 Rails 專案,我想 request spec 還是可以 cover 大部分的情況啦

使用方式

我就不介紹 RSpec 引入 Rails 的方式了,直接介紹 request spec

安裝

如果想要在 Request spec 用 rails routing 的 helpr 的話,像 root_url 這種,可以在 spec_helper 引入

RSpec.configure do |config|
  config.include Rails.application.routes.url_helpers, type: :request
  # ...
end
Enter fullscreen mode Exit fullscreen mode
  1. 其實只要將在 spec/ 下的測試檔 *_spec.rb 的 RSpec.describe 後加上 type: :request , rspec 即知道要做 request spec 了
RSpec.describe "/some/path", type: :request do
  # spec 的內容
end
Enter fullscreen mode Exit fullscreen mode
  1. 或是把 *_spec.rb 的測試檔放到 spec/requests/下, RSpec 也會直接假設那些檔案都是要做 request spec ## 如何做 request? Request spec 既然要測 HTTP 請求(request),當然也有提供對應的方法了:
  2. get
  3. post
  4. patch
  5. put
  6. delete 這些方式都是長這樣: post(url, options = {}) 可以放 paramsheaders 在 options 裡。
# 這些方法後面的 url 可寫完整路徑,也可以用 route 的方法
get root_url
get "/articles?page=3"
post users_url, params: "{\"name\": \"Kevin\"}", headers: {"Content-Type" => "application/json"}
patch "/users/2", params: "{\"height\": 183}", headers: {"Content-Type" => "application/json"}
delete user_url(User.find(2)), headers: {"Authorization" => "Bearer #{@token}"}
Enter fullscreen mode Exit fullscreen mode

常見問題,如何傳檔案?

可以利用 Rack::Test::UploadedFile, 例如

let(:filepath) { Rails.root.join('spec', 'fixtures', 'blank.jpg') }
let(:file) { Rack::Test::UploadedFile.new(filepath, 'image/jpg') }
# 在測試中可這樣用
post upload_image_url, params: {file: file}
Enter fullscreen mode Exit fullscreen mode

如何做斷言(assertions)?

斷言是測試中最重要的事,在 rspec 裡就是那些 expect
我們可以用 expect 去驗證 response 及controller 內執行完的結果
測 request test 比較接近"黑盒測試",也就是盡量只要管出輸入輸出殆可。

我們可以用 @responseresponse來取得回應的物件

# 我們可以驗證 response 的 http 狀態
expect(response).to have_http_status(:ok) # 200
expect(response).to have_http_status(:accepted) # 202
expect(response).to have_http_status(:not_found) # 404

# 我們可以驗證 redirect 的網址
expect(response).to redirect_to(articles_url)

# 我們可以驗證是渲染了哪個 template 或 partial
expect(response).to render_template(:index)
expect(response).to render_template("articles/_article")

# 可以驗證 response body 的容易字串
expect(response.body).to include("<h1>Hello World</h1>")

# 也可以直接看說有沒有對資料庫存取
expect {
  post articles_url, params: {title: 'A new article'}
}.to change{ Article.count }.by(1)
Enter fullscreen mode Exit fullscreen mode

有辦法做更細的 DOM 斷言嗎?

是可以的,我們可以利用 ActionController::Assertions::SelectorAssertions

# assert_select 可以直接用 css 選擇器去選 id="some_element" 的元素
assert_select "#some_element" 

# 直接驗證所有的 ol 都要有 4 個 li
assert_select "ol" do |elements|
  elements.each do |element|
    assert_select element, "li", 4
  end
end

assert_select "ol" do
  assert_select "li", 8
end
Enter fullscreen mode Exit fullscreen mode

在 APIdock 上有更詳細的文件 assert_select (ActionController::Assertions::SelectorAssertions) - APIdock

在 spec example可存取的變數

除了剛剛提到的 @reponse ,下面變數也可以存取

  • assigns: instance variable 像 @user 可以從 assigns 來存取,例assigns[:user]
  • sessions
  • flash
  • cookies

如何跟 Devise 整合?

We can use Devise helper if we use Devise to do the authentication.
如果有用 Devise 做登入的話,可以使用 Devise 的 Devise::Test::IntegrationHelpers

# spec_helper.rb
RSpec.configure do |config|
  config.include Devise::Test::IntegrationHelpers, type: :request # to sign_in user by Devise
end

# 在範例中可以用 sign_in 登入
let(:user) { create(:user) }
it "an example" do
  sign_in user
  get "/articles"
  expect(response).to have_http_status(:ok)
  expect(response).to render_template(:index)
end
Enter fullscreen mode Exit fullscreen mode

因為許多頁面都需要使用者登入,所我通常會做一個 shared_context,然後在需要的地方引入

RSpec.shared_context :login_user do
  let(:user) { create(:user) }
  before { sign_in user }
end

# then use include_context to include it
include_context :login_user
Enter fullscreen mode Exit fullscreen mode

個人經驗

其實老實說我以前也很少寫 request spec。
因為我覺得 model 的方法是真的邏輯在的地方,controller 只是把 model 方法的結果帶到 erb 去渲染而已。我們幹嘛去驗證這一層? 這應該是 Rails 本身的功能,是它負責的。

但我的想法太理想了..., 現實的狀況花樣百出,許多畫面或 API 都是混合一堆 model 的方法或多個 service object 的結果, erb 裡也常有很複雜的方法
Ruby 也不是強型別的語言, 所以也看不出來什麼問題,最後上線後就直接給你一個500
alt text

當這個紅畫面出現時,即使工程師說:「ok啦...小問題,沒有錯誤資料寫入資料庫,一下就修復!」我不覺得你老闆聽到會有多開心...

我覺得完整的 request spec 可以減少這類問題發生的機率。
尤其是 Request spec 包含了幾乎可以說是全棧的互動在內。如果 Request spec 有過,那幾乎等於真的在瀏覽器上也可以過了。

我發現用 scaffold 產生的 request spec 的結構非常好,十分推薦大家直接用它的架構,我貼在這:

RSpec.describe "/articles", type: :request do
  let(:valid_attributes) {
    skip("加 controller strong params 允許的全部參數")
  }

  let(:invalid_attributes) {
    skip("加非法的參數")
  }

  describe "GET /index" do
    it "renders a successful response" do
      Article.create! valid_attributes
      get articles_url
      expect(response).to be_successful
    end
  end

  describe "GET /show" do
    it "renders a successful response" do
      article = Article.create! valid_attributes
      get article_url(article)
      expect(response).to be_successful
    end
  end

  describe "GET /new" do
    it "renders a successful response" do
      get new_article_url
      expect(response).to be_successful
    end
  end

  describe "GET /edit" do
    it "render a successful response" do
      article = Article.create! valid_attributes
      get edit_article_url(article)
      expect(response).to be_successful
    end
  end

  describe "POST /create" do
    context "with valid parameters" do
      it "creates a new Article" do
        expect {
          post articles_url, params: { article: valid_attributes }
        }.to change(Article, :count).by(1)
      end

      it "redirects to the created article" do
        post articles_url, params: { article: valid_attributes }
        expect(response).to redirect_to(article_url(Article.last))
      end
    end

    context "with invalid parameters" do
      it "does not create a new Article" do
        expect {
          post articles_url, params: { article: invalid_attributes }
        }.to change(Article, :count).by(0)
      end

      it "renders a successful response (i.e. to display the 'new' template)" do
        post articles_url, params: { article: invalid_attributes }
        expect(response).to be_successful
      end
    end
  end

  describe "PATCH /update" do
    context "with valid parameters" do
      let(:new_attributes) {
        skip("Add a hash of attributes valid for your model")
      }

      it "updates the requested article" do
        article = Article.create! valid_attributes
        patch article_url(article), params: { article: new_attributes }
        article.reload
        skip("Add assertions for updated state")
      end

      it "redirects to the article" do
        article = Article.create! valid_attributes
        patch article_url(article), params: { article: new_attributes }
        article.reload
        expect(response).to redirect_to(article_url(article))
      end
    end

    context "with invalid parameters" do
      it "renders a successful response (i.e. to display the 'edit' template)" do
        article = Article.create! valid_attributes
        patch article_url(article), params: { article: invalid_attributes }
        expect(response).to be_successful
      end
    end
  end

  describe "DELETE /destroy" do
    it "destroys the requested article" do
      article = Article.create! valid_attributes
      expect {
        delete article_url(article)
      }.to change(Article, :count).by(-1)
    end

    it "redirects to the articles list" do
      article = Article.create! valid_attributes
      delete article_url(article)
      expect(response).to redirect_to(articles_url)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

其實會發現沒有那麼難寫,如果想要它變得短一些,也可以用 FactoryBot 之類的工具
另外,如果是 create 或 update 成功的測試,我會再進一步去驗證紀錄的內容:

RSpec.describe "/articles", type: :request do
  let(:valid_attributes) {
    {
      title: '文章標題',
      contenxt: '這是一篇文章'
    }
  }
  describe "POST /create" do
    context "with valid parameters" do
      it "creates a new Article" do
        expect {
          post articles_url, params: { article: valid_attributes }
        }.to change(Article, :count).by(1)
        article = Article.last
        # 不論有多少個 attributes,每一個我都會分別驗證 
        # 而不會再用任何 "聰明的" 方式去減少要寫的程式碼了
        # 我不想造成偽陰性
        expect(article.title).to eq('文章標題')
        expect(article.content).to eq('這是一篇文章')
      end
    end
  end
Enter fullscreen mode Exit fullscreen mode

結論

我現在心中的測試金字塔約略長這樣:
alt text

(system tests 是指 end-to-end test,Rails 這樣命名...)
金字塔的寬度是測試的數量。
雖然 Model tests 是最多,但它們通常就是測一個 model 的一個方法,是一個很小的範圍,在 rails 裡可算是 unit test。

很多團隊根本就完全手動執行 system test,如果運氣好的話,會有專門的 QA 團隊協助。但隨時系統功能越來越多, QA 團隊會有一個超級龐大的測試清單。為了完成全部的測試項目,交付程式碼的時程會越來越長。QA 也會過勞而系統又持續一堆 bug.

如果我們加了 model test 再上一層的 request test,我們可以減少手動測試的數量,除了系統更穩定外也避免 QA 集體離職...。QA 可以做更關鍵的測試,比如信用卡付款之類的;而不是為了怕有一些頁面可能會壞掉,所以每次部署前都要點開所有頁面一遍這種沒什麼特別意義的測試。

參考資料:

💖 💪 🙅 🚩
kevinluo201
Kevin Luo

Posted on September 5, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

介紹 RSpec Request Spec
rails 介紹 RSpec Request Spec

September 5, 2021