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_not 和 should_clean 這兩個 gem,他們教你如何在 RSpec 實踐上述原則以及清理手上那些用 "should" 開頭的測項。
用 guard 自動化測試
一對程式做了修改就得跑過所有測項可能會成為負擔,這會消秏許多時間而且打斷你的 flow。Guard 可以基於你正在修改的測項本身、model、controller 或是檔案,從完整的測試裡只挑出相關的測項執行。
good
bundle exec guard
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。解決方法是利用 Zeus、 Spin、或 Spork 這類的工具。 它們會預先載入所有你通常不會改到的函式庫,然後再重新載入那些你經常改動的 controller、model、view、factory 等檔案。
這裡提供你基於 Spork 設置的 spec helper 和 Guardfile。這個設定會在預先載入的檔案 (像是 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.
Books
<%= render "partials/books" %>Presentations
Resources on the web
<%= render "partials/links" %>Screencasts
<%= render "partials/screencasts" %>Libraries (documentation)
<%= render "partials/libraries" %>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)
Credits
A special thanks to the Lelylan Team. This document is licensed under MIT License.
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.