Ruby 介绍 RSpec Request Spec

kevinluo201 · 2021年09月05日 · 455 次阅读

https://dev.to/kevinluo201/rspec-request-spec-4781

这次想介绍 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 就有较高的机会前提发现这些错误。

下图是一个机器运作正常但还是出现意料之外结果的范例:
这是 gif,好像上载失败了...就当它是倒垃圾失败的吧

我觉得写 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 到下一页 ```ruby 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


## 什麽时候不适合用 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 引入
```ruby
RSpec.configure do |config|
  config.include Rails.application.routes.url_helpers, type: :request
  # ...
end
  1. 其实只要将在 spec/ 下的测试档 *_spec.rb 的 RSpec.describe 后加上 type: :request ,rspec 即知道要做 request spec 了 ruby RSpec.describe "/some/path", type: :request do # spec 的内容 end
  2. 或是把 *_spec.rb 的测试档放到 spec/requests/下,RSpec 也会直接假设那些档案都是要做 request spec ## 如何做 request? Request spec 既然要测 HTTP 请求 (request),当然也有提供对应的方法了:
  3. get
  4. post
  5. patch
  6. put
  7. delete 这些方式都是长这样: post(url, options = {}) 可以放 paramsheaders 在 options 裡。 ruby # 这些方法后面的 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}"}

常见问题,如何传档案?

可以利用 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}

如何做断言 (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)

有办法做更细的 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

在 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

因为许多页面都需要使用者登入,所我通常会做一个 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

个人经验

其实老实说我以前也很少写 request spec。 因为我觉得 model 的方法是真的逻辑在的地方,controller 只是把 model 方法的结果带到 erb 去渲染而已。我们干嘛去验证这一层?这应该是 Rails 本身的功能,是它负责的。

但我的想法太理想了..., 现实的状况花样百出,许多画面或 API 都是混合一堆 model 的方法或多个 service object 的结果,erb 裡也常有很複杂的方法 Ruby 也不是强型别的语言,所以也看不出来什麽问题,最后上线后就直接给你一个 500

当这个红画面出现时,即使工程师说:「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

其实会发现没有那麽难写,如果想要它变得短一些,也可以用 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

结论

我现在心中的测试金字塔约略长这样: (system tests 是指 end-to-end test,Rails 这样命名...) The width of each level mean the number of the tests should exist in the system. 金字塔的宽度是测试的数量。 虽然 Model tests 是最多,但它们通常就是测一个 model 的一个方法,是一个很小的范围,在 rails 裡可算是 unit test。

很多团队根本就完全手动执行 system test,如果运气好的话,会有专门的 QA 团队协助。但随时系统功能越来越多,QA 团队会有一个超级庞大的测试清单。为了完成全部的测试项目,交付程式码的时程会越来越长。QA 也会过劳而系统又持续一堆 bug.

如果我们加了 model test 再上一层的 request test,我们可以减少手动测试的数量,除了系统更稳定外也避免 QA 集体离职...。QA 可以做更关键的测试,比如信用卡付款之类的;而不是为了怕有一些页面可能会坏掉,所以每次部署前都要点开所有页面一遍这种没什麽特别意义的测试。

参考资料:

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号