如何进行高效的Rails单元测试
在笔者开发的系统中,有大量的数据需要分析,不仅要求数据分析准确,而且对速度也有一定的要求的。没有写测试代码之前,笔者用几个很大的方法来实现这种需求。结果可想而知,代码繁杂,维护困难,难于扩展。借业务调整的机会,笔者痛定思痛,决定从测试代码做起,并随着不断地学习和应用,慢慢体会到测试代码的好处。\改变思路:能做到从需求到代码的过程转换,逐步细化;\简化代码:力图让每个方法都很小,只专注一件事...
在笔者开发的系统中,有大量的数据需要分析,不仅要求数据分析准确,而且对速度也有一定的要求的。没有写测试代码之前,笔者用几个很大的方法来实现这种需求。结果可想而知,代码繁杂,维护困难,难于扩展。借业务调整的机会,笔者痛定思痛,决定从测试代码做起,并随着不断地学习和应用,慢慢体会到测试代码的好处。
\- 改变思路:能做到从需求到代码的过程转换,逐步细化;\
- 简化代码:力图让每个方法都很小,只专注一件事;\
- 优化代码:当测试代码写不出来,或者需要写很长的时候,说明代码是有问题的,是可以被分解的,需要进一步优化;\
- 便于扩展:当扩展新业务或修改旧业务时,如果测试代码没有成功,则说明扩展和修改不成功;\
- 时半功倍:貌似写测试代码很费时,实际在测试、部署和后续扩展中,测试代码将节省更多的时间。\
环境搭建
\笔者采用的测试环境是比较流行通用的框架:RSpec + Factory Girl,并用autotest自动工具。RSpec是一种描述性语言,通过可行的例子描述系统行为,非常容易上手,测试用例非常容易理解。Factory Girl可以很好的帮助构造测试数据,免去了自己写fixture的烦恼。Autotest能自动运行测试代码,随时检测测试代码的结果,并且有很多的插件支持,可以让测试结果显示的很炫。
\第一步 安装rspec和rspec-rails
\在命令行中执行如下命令:
\\$ sudo gem install rspec v = 1.3.0\$ sudo gem install rspec-rails v = 1.3.2\\
安装完成后,进入rails应用所在的目录,运行如下脚本,生成spec测试框架:
\\$ 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
\在命令行中执行如下命令:
\\$ sudo gem install factory-girl\\
在config/environment/test.rb中,加入factory-girl这个gem:
\\config.gem \"factory_girl\"\\
在spec/目录下,增加一个factories.rb的文件,用于所有预先定义的model工厂。
\第三步 安装autotest
\在命令行中执行如下命令:
\\$ sudo gem install ZenTest\$ sudo gem install autotest-rails\\
然后设置与RSpec的集成,在rails应用的目录下,运行如下的命令,就可以显示测试用例的运行结果。
\\RSPEC=true autotest or autospec\\
在自己的home目录下,增加一个.autotest设置所有的Rails应用的autotest插件。当然,也可以把这个文件加到每个应用的根目录下,这个文件将覆盖home目录下的文件设置。autotest的插件很多,笔者用到如下的plugin:
\\$ sudo gem install autotest-growl\$ sudo gem install autotest-fsevent\$ sudo gem install redgreen\\
设置.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\\
测试经验
\安装了必要的程序库以后,就可以写测试代码了。本例中,所有应用都是在Rails 2.3.4上开发的,RSpec采用的是1.3.0的版本。为了很好的说明问题,我们假定这样的需求:判断一个用户在一个时间段内是否迟到。写测试代码时都是遵循一个原则,只关心输入和输出,具体的实现并不在测试代码的考虑范围之内,是行为驱动开发。根据这个需求,我们将会设计方法absence_at(start_time,end_time)
,有两个输入值start_time
和end_time
以及一个输出值,类型是boolean。对应的测试代码如下:
\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\\
测试代码已经完成。至于absence_at
方法我们并不关心它的实现,只要这个方法的结果能让测试代码运行结果正确就可以。在此测试代码的基础上,就可以大胆地去完成代码,并根据测试代码的结果不断修改代码直到所有测试用例通过。
Stub的使用
\写测试代码,最好首先从model开始。因为model的方法能很好与输入输出的原则吻合,容易上手。最初的时候,你会发现mock和stub很好用,任何的对象都可以mock,并且在它的基础上可以stub一些方法,省去构造数据的麻烦,一度让笔者觉得测试代码是如此美丽,一步步的深入,才发现自己陷入了stub的误区。还是引用上面的例子,我们的代码实现如下:
\\class User \u0026lt; 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\\
按照最初写测试代码的思路,本方法中存在三种情况,即需要三个用例,而且还调用了其他两个方法,需要对他们进行stub,于是就有了下面的测试代码。记得当时完成后还很兴奋,心中还想:这么写测试代码真有趣。
\\before(:each) do\ @user = User.new\end\\describe \"method \u0026lt;absence_at(start_time,end_time)\u0026gt;\" 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\\
上面的测试代码,是典型把代码的实现细节带到了测试代码中,完全是本末倒置的。当然这个测试代码运行的时候,结果都是正确的。那是因为用stub来假定所有的子方法都是对的,但是如果这个子方法have_connection_or_review?
发生变化,它不返回boolean值,那么将会发生什么呢?这个测试代码依然正确,可怕吧!这都没有起到测试代码的作用。
另外,如果是这样,我们不仅要修改have_connection_or_review?
的测试代码,而且还要修改absence_at
的测试代码。这不是在增大代码维护量吗?
相比而言,不用stub的测试代码,不用修改,如果Factory的数据没有发生变化,那么测试代码的结果将是错误的,因为have_connection_or_review?
没有通过测试,导致absence_at
方法无法正常运行。
其实stub主要是mock一些本方法或者本应用中无法得到的对象,比如在tech_finish?
方法中,调用了一个file_service来获得Record对象的所有文件,在本方法测试代码运行过程中,无法得到这个service,这时stub就起作用了:
\class A \u0026lt; 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 \u0026lt; ActiveRecord::Base\ belongs_to :a\ has_files # here is a service in gem\end\\
所对应的测试代码如下:
\\describe \"tech_finish?\" do\ it \"should return true when A’s records have five files\" do\ record = Factory(:record)\ app = Factory(:a,:records=\u0026gt;[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=\u0026gt;[record])\ record.stub!(:files).and_return([1,2,3,5]) \ app.tech_finish?.should == false\ end\end\\
Factory的使用
\有了这个工厂,可以很方便的构造不同的模拟数据来运行测试代码。还是上面的例子,如果要测试absence_at
方法,涉及到多个model:
- HistoryRecord:User的上课记录\
- Calendar:User的课程表\
- Logging:User的日志信息\
如果不用factory-girl构造测试数据,我们将不得不在fixture构造这些测试数据。在fixture构造的数据无法指定是那个测试用例使用,但是如果用Factory的话,可以为这个方法专门指定一组测试数据。
\\Factory.define :user_absence_example,:class =\u0026gt; User do |user|\ user.login \"test\"\ class \u0026lt;\u0026lt; user\ def default_history_records\ [Factory.build(:history_record,:started_at=\u0026gt;Time.now),\ Factory.build(:history_record,:started_at=\u0026gt;Time.now)]\ end\ def default_calendars\ [Factory.build(:calendar),\ Factory.build(:calendar)] \ end\ def default_loggings\ [Factory.build(:logging,:started_at=\u0026gt;1.days.ago),\ Factory.build(:logging,:started_at=\u0026gt;1.days.ago)]\ end\ end\ user.history_records {default_history_records}\ user.calendars {default_calendars}\ user.loggings {default_loggings}\end\\
这个测试数据的构造工厂,可以放在factories.rb文件中,方便其他测试用例使用,也可以直接放到测试文件的before中,仅供本测试文件使用。通过factory的构造,不仅可以为多个测试用例共享同一组测试数据,而且测试代码也简洁明了。
\\before :each do\ @user = Factory.create(:user_absence_example)\end\\
Readonly的测试
\在笔者的系统中,大量使用了acts_as_readonly,从另外一个数据库来读取数据。由于这些model并不在本系统中,所以当用Factory构造测试数据的时候,总会有问题。虽然也可以使用mock来达到这个目的,但是由于mock的局限性,还是无法灵活的满足构造测试数据的需要。为此,扩展了一些代码,使得这些model依然可以测试。核心思想则是,根据配置文件的设置,将对应的readonly的表创建在测试数据库,这个操作在运行测试之前执行,这样就达到与其他model一样的效果。site_config配置文件中,关于readonly的配置格式如下:
\\readonly_for_test:\ logings:\ datetime: created_at\ string: status\ integer: trainer_id\\
Gem的测试
\Gem在Rails中被广泛使用,而且是最基础的东西,因此它的准确无误就显得更加重要。在不断实践的基础上,笔者所在的团队总结出一种用spec测试gem的方法。假设我们要测试的gem是platform_base,步骤如下:
\1. 在gem的根目录下创建一个目录spec(路径为platform_base/spec)。
\2. 在gem的根目录下创建文件Rakefile(路径为platform_base/Rakefile),内容如下:
\\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目录下创建spec.opts(路径为platform_base/spec/spec.opts),内容如下:
\\--colour\--format progress\--loadby mtime\--reverse\\
4. 在spec目录下,创建一个Rails app,名为test_app。这个新应用需要有spec目录和spec_helper.rb文件。
\5. 为了保持简化,把这个新app(test_app)整理一下,删除vendor和public目录,最终的结构如下:
\\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文件,内容如下:
\\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 =\u0026gt; 1).should == \"http://www.idapted.com/article/articles/1/comments\"\ @controller.url_of(:article, :comments, :article_id =\u0026gt; 1, :params=\u0026gt;{:category=\u0026gt;\"good\"}).should == \"http://www.idapted.com/article/articles/1/comments?category=good\"\ end\ end\end\\
至此,准备工作已经就绪,可以在platform_base目录下,运行rake spec
来进行测试,当然现在什么都不会发生,因为还没有测试代码呢。本方法中,最关键的就是下面的require语句,不仅加载了Rails environment,而且把gem在test_app中使用并测试。
\require File.join(File.dirname(__FILE__), 'test_app/spec/spec_helper')\\
Controller的测试
\对于controller的测试,一般来说比较简单,基本是三段式:初始化参数、请求方法、返回render或者redirect_to。如下例中,对某个controller的index方法的测试:
\\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 =\u0026gt; “test”}\ response.should render_template(\"index\")\ end\end\\
有些controller会设置session或者flash,这时的测试代码就一定要检查这个值设置的是否正确,而且还需要增加测试用例来覆盖不同的值,这样才能对方法进行全面的测试。如下例:
\\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 =\u0026gt; \"abc@eleutian.com\"}\ response.should redirect_to(users_path)\ flash[:notice].should == \"Create Successful!\"\ end\end\\
同时,也需要对controller的assigns进行测试,以保证返回正确的数据。如下例:
\\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 =\u0026gt; @course.id, :flag =\u0026gt;\"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 =\u0026gt; @course.id, :flag =\u0026gt;\"assess\"\ response.should render_template(\"show\")\ assigns[:test_paper].should == @course\ assigns[:flag].should == \"assess\"\ end \end\\
View的测试
\View的测试代码写的比较少,基本上是把核心的view部分集成到controller中来测试。主要用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=?]\
更多推荐
所有评论(0)