高效的單元測(cè)試Rails該怎樣進(jìn)行
在筆者開發(fā)的系統(tǒng)中,有大量的數(shù)據(jù)需要分析,不僅要求數(shù)據(jù)分析準(zhǔn)確,而且對(duì)速度也有一定的要求的。沒有寫測(cè)試代碼之前,筆者用幾個(gè)很大的方法來實(shí)現(xiàn)這種需求。結(jié)果可想而知,代碼繁雜,維護(hù)困難,難于擴(kuò)展。借業(yè)務(wù)調(diào)整的機(jī)會(huì),筆者痛定思痛,決定從測(cè)試代碼做起,并隨著不斷地學(xué)習(xí)和應(yīng)用,慢慢體會(huì)到測(cè)試代碼的好處。
- 改變思路:能做到從需求到代碼的過程轉(zhuǎn)換,逐步細(xì)化;
- 簡(jiǎn)化代碼:力圖讓每個(gè)方法都很小,只專注一件事;
- 優(yōu)化代碼:當(dāng)測(cè)試代碼寫不出來,或者需要寫很長(zhǎng)的時(shí)候,說明代碼是有問題的,是可以被分解的,需要進(jìn)一步優(yōu)化;
- 便于擴(kuò)展:當(dāng)擴(kuò)展新業(yè)務(wù)或修改舊業(yè)務(wù)時(shí),如果測(cè)試代碼沒有成功,則說明擴(kuò)展和修改不成功;
- 時(shí)半功倍:貌似寫測(cè)試代碼很費(fèi)時(shí),實(shí)際在測(cè)試、部署和后續(xù)擴(kuò)展中,測(cè)試代碼將節(jié)省更多的時(shí)間。
環(huán)境搭建
筆者采用的測(cè)試環(huán)境是比較流行通用的框架:RSpec + Factory Girl,并用autotest自動(dòng)工具。RSpec是一種描述性語言,通過可行的例子描述系統(tǒng)行為,非常容易上手,測(cè)試用例非常容易理解。Factory Girl可以很好的幫助構(gòu)造測(cè)試數(shù)據(jù),免去了自己寫fixture的煩惱。Autotest能自動(dòng)運(yùn)行測(cè)試代碼,隨時(shí)檢測(cè)測(cè)試代碼的結(jié)果,并且有很多的插件支持,可以讓測(cè)試結(jié)果顯示的很炫。
第一步 安裝rspec和rspec-rails
在命令行中執(zhí)行如下命令:
- $ sudo gem install rspec v = 1.3.0
- $ sudo gem install rspec-rails v = 1.3.2
安裝完成后,進(jìn)入rails應(yīng)用所在的目錄,運(yùn)行如下腳本,生成spec測(cè)試框架:
- $ script/generate rspec
- exists lib/tasks
- identical lib/tasks/rspec.rake
- identical script/autospec
- identical script/spec
- exists spec
- identical spec/rcov.opts
- identical spec/spec.opts
- identical spec/spec_helper.rb
第二步 安裝factory-girl
在命令行中執(zhí)行如下命令:
- $ sudo gem install factory-girl
在config/environment/test.rb中,加入factory-girl這個(gè)gem:
- config.gem "factory_girl"
在spec/目錄下,增加一個(gè)factories.rb的文件,用于所有預(yù)先定義的model工廠。
第三步 安裝autotest
在命令行中執(zhí)行如下命令:
- $ sudo gem install ZenTest
- $ sudo gem install autotest-rails
然后設(shè)置與RSpec的集成,在rails應(yīng)用的目錄下,運(yùn)行如下的命令,就可以顯示測(cè)試用例的運(yùn)行結(jié)果。
- RSPEC=true autotest or autospec
在自己的home目錄下,增加一個(gè).autotest設(shè)置所有的Rails應(yīng)用的autotest插件。當(dāng)然,也可以把這個(gè)文件加到每個(gè)應(yīng)用的根目錄下,這個(gè)文件將覆蓋home目錄下的文件設(shè)置。autotest的插件很多,筆者用到如下的plugin:
- $ sudo gem install autotest-growl
- $ sudo gem install autotest-fsevent
- $ sudo gem install redgreen
設(shè)置.autotest文件,在.autotest中,加入如下代碼。
- require 'autotest/growl'
- require 'autotest/fsevent'
- require 'redgreen/autotest'
- Autotest.add_hook :initialize do |autotest|
- %w{.git .svn .hg .DS_Store ._* vendor tmp log doc}.each do |exception|
- autotest.add_exception(exception)
- end
- end
#p#
測(cè)試經(jīng)驗(yàn)
安裝了必要的程序庫以后,就可以寫測(cè)試代碼了。本例中,所有應(yīng)用都是在Rails 2.3.4上開發(fā)的,RSpec采用的是1.3.0的版本。為了很好的說明問題,我們假定這樣的需求:判斷一個(gè)用戶在一個(gè)時(shí)間段內(nèi)是否遲到。寫測(cè)試代碼時(shí)都是遵循一個(gè)原則,只關(guān)心輸入和輸出,具體的實(shí)現(xiàn)并不在測(cè)試代碼的考慮范圍之內(nèi),是行為驅(qū)動(dòng)開發(fā)。根據(jù)這個(gè)需求,我們將會(huì)設(shè)計(jì)方法absence_at(start_time,end_time),有兩個(gè)輸入值start_time和end_time以及一個(gè)輸出值,類型是boolean。對(duì)應(yīng)的測(cè)試代碼如下:
- describe "User absence or not during [start_time,end_time]" do
- before :each do
- @user = Factory(:user)
- end
- it "should return false when user not absence " do
- start_time = Time.utc(2010,11,9,12,0,0,0)
- end_time = Time.utc(2010,11,9,12,30,0)
- @user.absence_at(start_time,end_time).should be_false
- end
- it "should return true when user absence " do
- start_time = Time.utc(2010,11,9,13,0,0,0)
- end_time = Time.utc(2010,11,9,13,30,0)
- @user.absence_at(start_time,end_time).should be_ture
- end
- end
測(cè)試代碼已經(jīng)完成。至于absence_at方法我們并不關(guān)心它的實(shí)現(xiàn),只要這個(gè)方法的結(jié)果能讓測(cè)試代碼運(yùn)行結(jié)果正確就可以。在此測(cè)試代碼的基礎(chǔ)上,就可以大膽地去完成代碼,并根據(jù)測(cè)試代碼的結(jié)果不斷修改代碼直到所有測(cè)試用例通過。
Stub的使用
寫測(cè)試代碼,最好首先從model開始。因?yàn)閙odel的方法能很好與輸入輸出的原則吻合,容易上手。最初的時(shí)候,你會(huì)發(fā)現(xiàn)mock和stub很好用,任何的對(duì)象都可以mock,并且在它的基礎(chǔ)上可以stub一些方法,省去構(gòu)造數(shù)據(jù)的麻煩,一度讓筆者覺得測(cè)試代碼是如此美麗,一步步的深入,才發(fā)現(xiàn)自己陷入了stub的誤區(qū)。還是引用上面的例子,我們的代碼實(shí)現(xiàn)如下:
- class User < ActiveRecord::Base
- def absence_at(start_time,end_time)
- return false if have_connection_or_review?(start_time,end_time)
- return (login_absence_at?(start_time,end_time) ? true : false)
- end
- end
按照最初寫測(cè)試代碼的思路,本方法中存在三種情況,即需要三個(gè)用例,而且還調(diào)用了其他兩個(gè)方法,需要對(duì)他們進(jìn)行stub,于是就有了下面的測(cè)試代碼。記得當(dāng)時(shí)完成后還很興奮,心中還想:這么寫測(cè)試代碼真有趣。
- before(:each) do
- @user = User.new
- end
- describe "method <absence_at(start_time,end_time)>" do
- s = Time.now
- e = s + 30.minutes
- # example one
- it "should be false when user have interaction or review" do
- @user.stub!(:have_connection_or_review?).with(s,e).and_return(true)
- @user.absence_at(s,e).should be_false
- end
- # example two
- it "should be true when user has no interaction and he no waiting at platform" do
- @user.stub!(:have_connection_or_review?).with(s,e).and_return(false)
- @user.stub!(:login_absence_at?).with(s,e).and_return(true)
- @user.absence_at(s,e).should be_true
- end
- # example three
- it "should be false when user has no interaction and he waiting at platform" do
- @user.stub!(:have_connection_or_review?).with(s,e).and_return(false)
- @user.stub!(:login_absence_at?).with(s,e).and_return(false)
- @user.absence_at(s,e).should be_false
- end
- end
上面的測(cè)試代碼,是典型把代碼的實(shí)現(xiàn)細(xì)節(jié)帶到了測(cè)試代碼中,完全是本末倒置的。當(dāng)然這個(gè)測(cè)試代碼運(yùn)行的時(shí)候,結(jié)果都是正確的。那是因?yàn)橛胹tub來假定所有的子方法都是對(duì)的,但是如果這個(gè)子方法have_connection_or_review?發(fā)生變化,它不返回boolean值,那么將會(huì)發(fā)生什么呢?這個(gè)測(cè)試代碼依然正確,可怕吧!這都沒有起到測(cè)試代碼的作用。
另外,如果是這樣,我們不僅要修改have_connection_or_review?的測(cè)試代碼,而且還要修改absence_at的測(cè)試代碼。這不是在增大代碼維護(hù)量嗎?
相比而言,不用stub的測(cè)試代碼,不用修改,如果Factory的數(shù)據(jù)沒有發(fā)生變化,那么測(cè)試代碼的結(jié)果將是錯(cuò)誤的,因?yàn)閔ave_connection_or_review?沒有通過測(cè)試,導(dǎo)致absence_at方法無法正常運(yùn)行。
其實(shí)stub主要是mock一些本方法或者本應(yīng)用中無法得到的對(duì)象,比如在tech_finish?方法中,調(diào)用了一個(gè)file_service來獲得Record對(duì)象的所有文件,在本方法測(cè)試代碼運(yùn)行過程中,無法得到這個(gè)service,這時(shí)stub就起作用了:
- class A < ActiveRecord::Base
- has_many :records
- def tech_finish?
- self.records.each do |v_a|
- return true if v_a.files.size == 5
- end
- return false
- end
- end
- class Record < ActiveRecord::Base
- belongs_to :a
- has_files # here is a service in gem
- end
所對(duì)應(yīng)的測(cè)試代碼如下:
- describe "tech_finish?" do
- it "should return true when A’s records have five files" do
- record = Factory(:record)
- app = Factory(:a,:records=>[record])
- record.stub!(:files).and_return([1,2,3,4,5])
- app.tech_finish?.should == true
- end
- it "should return false when A’s records have less five files" do
- record = Factory(:record)
- app = Factory(:a,:records=>[record])
- record.stub!(:files).and_return([1,2,3,5])
- app.tech_finish?.should == false
- end
- end
Factory的使用
有了這個(gè)工廠,可以很方便的構(gòu)造不同的模擬數(shù)據(jù)來運(yùn)行測(cè)試代碼。還是上面的例子,如果要測(cè)試absence_at方法,涉及到多個(gè)model:
- HistoryRecord:User的上課記錄
- Calendar:User的課程表
- Logging:User的日志信息
如果不用factory-girl構(gòu)造測(cè)試數(shù)據(jù),我們將不得不在fixture構(gòu)造這些測(cè)試數(shù)據(jù)。在fixture構(gòu)造的數(shù)據(jù)無法指定是那個(gè)測(cè)試用例使用,但是如果用Factory的話,可以為這個(gè)方法專門指定一組測(cè)試數(shù)據(jù)。
- Factory.define :user_absence_example,:class => User do |user|
- user.login "test"
- class << user
- def default_history_records
- [Factory.build(:history_record,:started_at=>Time.now),
- Factory.build(:history_record,:started_at=>Time.now)]
- end
- def default_calendars
- [Factory.build(:calendar),
- Factory.build(:calendar)]
- end
- def default_loggings
- [Factory.build(:logging,:started_at=>1.days.ago),
- Factory.build(:logging,:started_at=>1.days.ago)]
- end
- end
- user.history_records {default_history_records}
- user.calendars {default_calendars}
- user.loggings {default_loggings}
- end
這個(gè)測(cè)試數(shù)據(jù)的構(gòu)造工廠,可以放在factories.rb文件中,方便其他測(cè)試用例使用,也可以直接放到測(cè)試文件的before中,僅供本測(cè)試文件使用。通過factory的構(gòu)造,不僅可以為多個(gè)測(cè)試用例共享同一組測(cè)試數(shù)據(jù),而且測(cè)試代碼也簡(jiǎn)潔明了。
- before :each do
- @user = Factory.create(:user_absence_example)
- end
#p#
Readonly的測(cè)試
在筆者的系統(tǒng)中,大量使用了acts_as_readonly,從另外一個(gè)數(shù)據(jù)庫來讀取數(shù)據(jù)。由于這些model并不在本系統(tǒng)中,所以當(dāng)用Factory構(gòu)造測(cè)試數(shù)據(jù)的時(shí)候,總會(huì)有問題。雖然也可以使用mock來達(dá)到這個(gè)目的,但是由于mock的局限性,還是無法靈活的滿足構(gòu)造測(cè)試數(shù)據(jù)的需要。為此,擴(kuò)展了一些代碼,使得這些model依然可以測(cè)試。核心思想則是,根據(jù)配置文件的設(shè)置,將對(duì)應(yīng)的readonly的表創(chuàng)建在測(cè)試數(shù)據(jù)庫,這個(gè)操作在運(yùn)行測(cè)試之前執(zhí)行,這樣就達(dá)到與其他model一樣的效果。site_config配置文件中,關(guān)于readonly的配置格式如下:
- readonly_for_test:
- logings:
- datetime: created_at
- string: status
- integer: trainer_id
Gem的測(cè)試
Gem在Rails中被廣泛使用,而且是最基礎(chǔ)的東西,因此它的準(zhǔn)確無誤就顯得更加重要。在不斷實(shí)踐的基礎(chǔ)上,筆者所在的團(tuán)隊(duì)總結(jié)出一種用spec測(cè)試gem的方法。假設(shè)我們要測(cè)試的gem是platform_base,步驟如下:
1. 在gem的根目錄下創(chuàng)建一個(gè)目錄spec(路徑為platform_base/spec)。
2. 在gem的根目錄下創(chuàng)建文件Rakefile(路徑為platform_base/Rakefile),內(nèi)容如下:
- require 'rubygems'
- require 'rake'
- require 'spec/rake/spectask'
- Spec::Rake::SpecTask.new('spec') do |t|
- t.spec_opts = ['--options', "spec/spec.opts"]
- t.spec_files = FileList['spec/**/*_spec.rb']
- end
3. 文件在spec目錄下創(chuàng)建spec.opts(路徑為platform_base/spec/spec.opts),內(nèi)容如下:
- --colour
- --format progress
- --loadby mtime
- --reverse
4. 在spec目錄下,創(chuàng)建一個(gè)Rails app,名為test_app。這個(gè)新應(yīng)用需要有spec目錄和spec_helper.rb文件。
5. 為了保持簡(jiǎn)化,把這個(gè)新app(test_app)整理一下,刪除vendor和public目錄,最終的結(jié)構(gòu)如下:
- test_app
- |- app
- |- config
- | |- environments
- | |- initializers
- | |- app_config.yml
- | |- boot.rb
- | |- database.yml
- | |- environment.rb
- | \- routes.rb
- |- db
- | \- test.sqlite3
- |- log
- \- spec
- \- spec_helper.rb
6. 在config/environment.rb配置文件中,增加如下代碼:
- Rails::Initializer.run do |config|
- config.gem 'rails_platform_base'
- end
7. 在platform_base/spec/目錄下增加helpers_spec.rb文件,內(nèi)容如下:
- require File.join(File.dirname(__FILE__), 'test_app/spec/spec_helper')
- describe "helpers" do
- describe "url_of" do
- before do
- Rails.stub!(:env).and_return("development")
- @controller = ActionController::Base.new
- end
- it "should get url from app's configration" do
- @controller.url_of(:article, :comments, :article_id => 1).should == "http://www.idapted.com/article/articles/1/comments"
- @controller.url_of(:article, :comments, :article_id => 1, :params=>{:category=>"good"}).should == "http://www.idapted.com/article/articles/1/comments?category=good"
- end
- end
- end
至此,準(zhǔn)備工作已經(jīng)就緒,可以在platform_base目錄下,運(yùn)行rake spec來進(jìn)行測(cè)試,當(dāng)然現(xiàn)在什么都不會(huì)發(fā)生,因?yàn)檫€沒有測(cè)試代碼呢。本方法中,最關(guān)鍵的就是下面的require語句,不僅加載了Rails environment,而且把gem在test_app中使用并測(cè)試。
- require File.join(File.dirname(__FILE__), 'test_app/spec/spec_helper')
#p#
Controller的測(cè)試
對(duì)于controller的測(cè)試,一般來說比較簡(jiǎn)單,基本是三段式:初始化參數(shù)、請(qǐng)求方法、返回render或者redirect_to。如下例中,對(duì)某個(gè)controller的index方法的測(cè)試:
- describe "index action" do
- it "should render report page with the current month report" do
- controller.stub!(:current_user).and_return(@user)
- get :index,{:flag => “test”}
- response.should render_template("index")
- end
- end
有些controller會(huì)設(shè)置session或者flash,這時(shí)的測(cè)試代碼就一定要檢查這個(gè)值設(shè)置的是否正確,而且還需要增加測(cè)試用例來覆蓋不同的值,這樣才能對(duì)方法進(jìn)行全面的測(cè)試。如下例:
- describe "create action" do
- it "should donot create new user with wrong params" do
- post :create
- response.should redirect_to(users_path)
- flash[:notice].should == "Create Fail!"
- end
- it "should create a new user with right params" do
- post :create, {:email => "abc@eleutian.com"}
- response.should redirect_to(users_path)
- flash[:notice].should == "Create Successful!"
- end
- end
同時(shí),也需要對(duì)controller的assigns進(jìn)行測(cè)試,以保證返回正確的數(shù)據(jù)。如下例:
- before(:each) do
- @course = Factory(:course)
- end
- describe "show action" do
- it "should render show page when flag != assess and success" do
- get :show, :id => @course.id, :flag =>"test"
- response.should render_template("show")
- assigns[:test_paper].should == @course
- assigns[:flag].should == "test"
- end
- it "should render show page when flag == assess and success" do
- get :show, :id => @course.id, :flag =>"assess"
- response.should render_template("show")
- assigns[:test_paper].should == @course
- assigns[:flag].should == "assess"
- end
- end
View的測(cè)試
View的測(cè)試代碼寫的比較少,基本上是把核心的view部分集成到controller中來測(cè)試。主要用integrate_views方法。如下例:
- describe AccountsController do
- integrate_views
- describe "index action" do
- it "should render index.rhtml" do
- get :index
- response.should render_template("index")
- response.should have_tag("a[href=?]",new_account_path)
- response.should have_tag("a[href=?]",new_session_path)
- end
- end
- end
總結(jié)展望
在寫測(cè)試代碼的時(shí)候,并不一定要事無巨細(xì),有些比較簡(jiǎn)單的方法以及Rails的內(nèi)部的方法,如named_scope,就完全沒有必要測(cè)試。本文中,只介紹了用rspec寫單元測(cè)試的代碼,對(duì)于集成測(cè)試沒有涉及,這也是今后努力的一個(gè)方向。
另外,用cumumber + rspec + webrat的BDD開發(fā)模式也是相當(dāng)不錯(cuò)的。尤其是cumumber對(duì)需求的描述,完全可以用它來做需求分析。
【編輯推薦】