RSpec 是一個好工具。它在 BDD 開發流程中被用來撰寫可讀性高的規格(測項),引導並驗證你所開發的應用程式。

網路上多半的資源告訴你 RSpec 能「做些什麼」,但很少討論如何使用它「做出好的規格(測項)」。

Better Specs 盡可能地收集開發者們經年累月習得的 "Best practice" 來幫助你達到這個目標。

如何描述 (describe) 你的 methods

清楚地描述你的 method。譬如在提到 class method 時加上 Ruby 文件慣用的 . (或 ::),在提到 instance method 時加上 #

bad

describe 'the authenticate method for User' do
describe 'if the user is an admin' do

good

describe '.authenticate' do
describe '#admin?' do

進一步討論由此去 →

使用 context

Context 讓你的測項更明確、有條理,在漫長的開發過程中保持可讀性。

bad

it 'has 200 status code if logged in' do
  response.should respond_with 200
end
it 'has 401 status code if not logged in' do
  response.should respond_with 401
end

good

context 'when logged in' do
  it { is_expected.to respond_with 200 }
end
context 'when logged out' do
  it { is_expected.to respond_with 401 }
end

描述 context 時,要用 "when" 或 "with" 做開頭。

進一步討論由此去 →

保持簡潔的 description

控制 spec 的描述字數在 40 字以內,超過的話應該用 context 分段。

bad

it 'has 422 status code if an unexpected params will be added' do

good

context 'when not valid' do
  it { should respond_with 422 }
end

上例中我們把 status code 相關的描述用測項本體 it { should respond_with 422 } 取代。 如果你用 rspec filename 執行這個測項,仍然會輸出具可讀性的報告。

Formatted Output

when not valid
  it should respond with 422

進一步討論由此去 →

測試單一條件

「單一條件」意指一個測項應該只帶有一個檢查 (expection, assertion)。這樣做能幫助你直接前往失敗的測項尋找可能的問題,也讓程式碼比較好看。

獨立的規格單元測項中,每一題應該只定義「一個行為」。用上多個檢查表示你可能在一題裡定義了多個行為。

但在涉及 DB 、外部 webservice、或是整合測試這類非獨立的測項裡,不斷做重覆的前置設定 (setup) 會拖慢測試效率,倒不如在一個測項放上多個檢查。我認為這種跑不快的測項可以一題檢查一個以上的行為。

good (isolated)

it { should respond_with_content_type(:json) }
it { should assign_to(:resource) }

Good (not isolated)

it 'creates a resource' do
  response.should respond_with_content_type(:json)
  response.should assign_to(:resource)
end
進一步討論由此去 →

驗證所有可能的情況

實行測試很好,但是如果測項沒有包括 edge case 的話,它並不能發揮最大的效用。有效的、無效的、和 edge case 都需要被驗證,可以參考下述範例的作法。

Destroy action

before_filter :find_owned_resources
before_filter :find_resource

def destroy
  render 'show'
  @consumption.destroy
end

我常在只針對是否成功移除 resource 的測項裡看到失誤。這類行為至少還包括兩個 edge case:要移除的 resource 不存在,以及無權限移除。切記,考慮所有可能的輸入值並對它們進行測試。

bad

it 'shows the resource'

good

describe '#destroy' do

  context 'when resource is found' do
    it 'responds with 200'
    it 'shows the resource'
  end

  context 'when resource is not found' do
    it 'responds with 404'
  end

  context 'when resource is not owned' do
    it 'responds with 404'
  end
end

進一步討論由此去 →

善用 subject

當多個測項針對的 subject 相同時,善用 subject{} 取代重覆的程式碼 (DRY)。

bad

it { assigns('message').should match /it was born in Belville/ }

good

subject { assigns('message') }
it { should match /it was born in Billville/ }

RSpec 同時提供命名 subject 的功能。

Good

subject(:hero) { Hero.first }
it "carries a sword" do
  hero.equipment.should include "sword"
end

請看更多關於 rspec subject 的資訊。

進一步討論由此去 →

善用 let 和 let!

當你需要指定 variable 時,用 let 代替 before 來建立 instance variable。 let 有 lazy load 特性,只在測項第一次用到該 variable 時被執行,並且會 cache 直到該測項結束。 想更深入瞭解 let 請參考這個 stackoverflow answer

bad

describe '#type_id' do
  before { @resource = FactoryGirl.create :device }
  before { @type     = Type.find @resource.type_id }

  it 'sets the type_id field' do
    @resource.type_id.should equal(@type.id)
  end
end

good

describe '#type_id' do
  let(:resource) { FactoryGirl.create :device }
  let(:type)     { Type.find resource.type_id }

  it 'sets the type_id field' do
    resource.type_id.should equal(type.id)
  end
end

let 進行的初始化會在測項執行時以 lazy load 方式完成。

good

context 'when updates a not existing property value' do
  let(:properties) { { id: Settings.resource_id, value: 'on'} }

  def update
    resource.properties = properties
  end

  it 'raises a not found error' do
    expect { update }.to raise_error Mongoid::Errors::DocumentNotFound
  end
end

如果想要 variable 在定義時就被建立,請用 let!。這個技巧在產生 database 內容以測試 query 和 scope 時十分好用。

以下是一個 let 的實例。

good

# this:
let(:foo) { Foo.new }

# is very nearly equivalent to this:
def foo
  @foo ||= Foo.new
end

請看更多關於 rspec let 的資訊。

進一步討論由此去 →

Mock 的時機

關於 mock 的用法仍有爭議。能對真實行為測試的時候,不要(過度)依賴 mock。真實的測項在你改善應用程式流程時十分有幫助。

good

# simulate a not found resource
context "when not found" do
  before { allow(Resource).to receive(:where).with(created_from: params[:id]).and_return(false) }
  it { should respond_with 404 }
end

Mock 能改善測項的執行速度,但它並不容易上手。你必須對 mock 更熟悉才能讓它正確地派上用場,請看更多的說明

進一步討論由此去 →

只建立必要的資料

如果你有參與過中型的專案 (有些小專案也如此),跑測試可能是件快不起來的工作。為了解決這個問題,千萬不要載入非必要的資料。如果你發現你需要上打的 record,你可能用錯方法了。

good

describe "User"
  describe ".top" do
    before { FactoryGirl.create_list(:user, 3) }
    it { User.top(2).should have(2).item }
  end
end

進一步討論由此去 →

取 factory 捨 fixture

這是個值得重彈的老調。不要用 fixture,它太難維護了。改用 fatory,它能減輕建立新資料負擔。

bad

user = User.create(
  name: 'Genoveffa',
  surname: 'Piccolina',
  city: 'Billyville',
  birth: '17 Agoust 1982',
  active: true
)

good

user = FactoryGirl.create :user

另外請看這篇文章。當討論到 unit test 的時候,最佳情況是不用 fixture 也不用 factory。盡可能把你的 domain logic 留在那些不用靠 factory 和 fixture 進行複雜耗時前置設定的函式庫裡。

請看更多關於 Factory Girl 的資訊。

進一步討論由此去 →

一目瞭然的 matcher

善用 rspec 內建 或意義簡明的 matcher 。

bad

lambda { model.save! }.should raise_error Mongoid::Errors::DocumentNotFound

good

expect { model.save! }.to raise_error Mongoid::Errors::DocumentNotFound

進一步討論由此去 →

通用測項

撰寫測項是個好習慣,能增加你開發過程中的信心。但漸漸你會發現裡頭出現越來越多重覆的程式碼,你需要通用測項讓你的測試更 DRY。

bad

describe 'GET /devices' do
  let!(:resource) { FactoryGirl.create :device, created_from: user.id }
  let(:uri) { '/devices' }

  context 'when shows all resources' do
    let!(:not_owned) { FactoryGirl.create factory }

    it 'shows all owned resources' do
      page.driver.get uri
      page.status_code.should be(200)
      contains_owned_resource resource
      does_not_contain_resource not_owned
    end
  end

  describe '?start=:uri' do
    it 'shows the next page' do
      page.driver.get uri, start: resource.uri
      page.status_code.should be(200)
      contains_resource resources.first
      page.should_not have_content resource.id.to_s
    end
  end
end

good

describe 'GET /devices' do

  let!(:resource) { FactoryGirl.create :device, created_from: user.id }
  let(:uri)       { '/devices' }

  it_behaves_like 'a listable resource'
  it_behaves_like 'a paginable resource'
  it_behaves_like 'a searchable resource'
  it_behaves_like 'a filterable list'
end

經驗上來看,通用測項主要用在 controller 上。因為不同 model 間差異較大,少有通用的邏輯。

請看更多關於 rspec shared examples 的資訊。

進一步討論由此去 →

測你所見

詳盡地檢驗 model 和應用程式的整合行為,不要浪費複雜卻無用的測試在 controller 上。

一開始測試 app 時,我花了精力在 controller 上,現在我不那麼做了。取而代之我只用 RSpec 和 Capybara 建立一些整合測項。 我的想法是你應該測試會被看見的東西,而對 controller 來說測試是多餘的。你會發現大部分的測項與 model 息息相關,同時整合性的測項很容易整理成通用測項,讓你的測試簡明易懂。

這個具爭議性的想法在 Ruby 社群中仍未定論,正反雙方都有好理由支持各自的論點。認為 controller 也需要測試的人會告訴你整合測試跑不快,而且無法窮舉所有情況。

他們錯了。你可以輕易測到所有可能,而且利用 Guard 這類自動化測試工具只執行單一檔案的測項。如此一來只會跑到需要驗證的測項,費時很短,不會擔誤你的 flow。

進一步討論由此去 →

Description 不提 should

別在測項描述提到 should,要用第三人稱的現在式。更進一步,你可以開始試用新的 expectation 語法。

bad

it 'should not change timings' do
  consumption.occur_at.should == valid.occur_at
end

good

it 'does not change timings' do
  expect(consumption.occur_at).to equal(valid.occur_at)
end

請問 should_notshould_clean 這兩個 gem,他們教你如何在 RSpec 實踐上述原則以及清理手上那些用 "should" 開頭的測項。

進一步討論由此去 →

用 guard 自動化測試

一對程式做了修改就得跑過所有測項可能會成為負擔,這會消秏許多時間而且打斷你的 flow。Guard 可以基於你正在修改的測項本身、model、controller 或是檔案,從完整的測試裡只挑出相關的測項執行。

good

bundle exec guard
以下 Guardfile 範例提供一些基本的載入規則。

good

guard 'rspec', cli: '--drb --format Fuubar --color', version: 2 do
  # 執行所有被修改的 spec
  watch(%r{^spec/.+_spec\.rb$})
  # 執行 lib 裡被修改的 file 對應的 lib spec
  watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
  # 執行被修改的 model 對應的 model spec
  watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
  # 執行被修改的 view 對應的 view spec
  watch(%r{^app/(.*)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
  # 執行與改動的 controller 相關的 integration spec
  watch(%r{^app/controllers/(.+)\.rb}) { |m| "spec/requests/#{m[1]}_spec.rb" }
  # 當 application controller 改動時執行所有的 integration test
  watch('app/controllers/application_controller.rb') { "spec/requests" }
end

Guard 好用但不能滿足你所有的需求。有時設一組快速鍵在你想測的時候執行你需要的測項,與你的 TDD 流程更合的來。然後你可以利用 rake task 在 push code 之前跑過完整的測試。這裡有些 給 vim 用的快速鍵設定

請看更多關於 guard-rspec 的資訊。

進一步討論由此去 →

用 spork 縮短測試時間

測試 Rails 時會載入整個 Rails app,這滿秏時並且可能打斷你開發的 flow。解決方法是利用 ZeusSpin、或 Spork 這類的工具。 它們會預先載入所有你通常不會改到的函式庫,然後再重新載入那些你經常改動的 controller、model、view、factory 等檔案。

這裡提供你基於 Spork 設置的 spec helperGuardfile。這個設定會在預先載入的檔案 (像是 initializer) 被改到時重新載入整個 app,執行單一測項的速度會非常非常地快。

Spork 的缺點在它過分地 monkey-patch 了你的程式碼,你可能花上半天試著搞懂為什麼沒有重新載入某個檔案。 如果你有使用 Spin 或其他解決方案的例子,請 與我們分享

這是使用 Zeus 的 Guardfile 設定。spec_helper 的部分不需要修改,但你必須在開始測試前開一個 console 執行 `zeus start`。

雖然 Zeus 採取不像 Spork 那麼激進的作法,它最大的問題在使用上有嚴格的要求:要求 Ruby 1.9.3+ (建議使用 Ruby 2.0 的 backported GC) 及支持 FSEvents 或 inotify 的作業系統。

很多對 Spork 不滿的人改用了其他的解決方案。但比起這些補救性質的工具,更好的作法是從設計上改進,以方便直接抓出那些相依的檔案。 請進一步參考下面討論連結裡的內容。

進一步討論由此去 →

偽裝 HTTP request

有時你會存取外部的服務,但沒辦法真的使用這些服務來測試。這時候就需要 webmock 這類工具來進行 stub。

good

context "with unauthorized access" do
  let(:uri) { 'http://api.lelylan.com/types' }
  before    { stub_request(:get, uri).to_return(status: 401, body: fixture('401.json')) }
  it "gets a not authorized notification" do
    page.driver.get uri
    page.should have_content 'Access denied'
  end
end

請看更多關於 webmock 的資訊和 影片。 另外有個不錯的 presentation 說明如何交互利用這些工具。

進一步討論由此去 →

好用的 formatter

使用 formatter 讓你的測試提供有用的訊息。我推薦 fuubar,只要安裝這個 gem 並在 Guardfile 裡設定 fuubar 為預設的 formatter 就可以用了。

good

# Gemfile
group :development, :test do
  gem 'fuubar'

good

# Guardfile
guard 'rspec' do
  # ...
end

good

# .rspec
--drb
--format Fuubar
--color

Learn more about fuubar.

進一步討論由此去 →

Styleguide

We are seeking for the best guidelines to write "nice to read" specs. Right now a good starting point is for sure the Mongoid test suite. It uses a clean style and easy-to-read specs, following most of the guidelines described here.

Improving Better Specs

This is an open source project. If something is missing or incorrect just file an issue to discuss the topic. Also check the following issues:

  • Multilanguage (file an issue if you want to translate this guide)

Help us

If you have found these tips useful and they improve your work, think about making a $9 donation. Any donations will be used to make this site a more compleate reference for better testing in Ruby.