产品战略专家梁宁确认出席AICon北京站,分享AI时代下的商业逻辑与产品需求 了解详情
写点什么

如何进行高效的 Rails 单元测试

  • 2011-06-18
  • 本文字数:6765 字

    阅读完需:约 22 分钟

在笔者开发的系统中,有大量的数据需要分析,不仅要求数据分析准确,而且对速度也有一定的要求的。没有写测试代码之前,笔者用几个很大的方法来实现这种需求。结果可想而知,代码繁杂,维护困难,难于扩展。借业务调整的机会,笔者痛定思痛,决定从测试代码做起,并随着不断地学习和应用,慢慢体会到测试代码的好处。

  • 改变思路:能做到从需求到代码的过程转换,逐步细化;
  • 简化代码:力图让每个方法都很小,只专注一件事;
  • 优化代码:当测试代码写不出来,或者需要写很长的时候,说明代码是有问题的,是可以被分解的,需要进一步优化;
  • 便于扩展:当扩展新业务或修改旧业务时,如果测试代码没有成功,则说明扩展和修改不成功;
  • 时半功倍:貌似写测试代码很费时,实际在测试、部署和后续扩展中,测试代码将节省更多的时间。

环境搭建

笔者采用的测试环境是比较流行通用的框架: 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_timeend_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 < 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 <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

上面的测试代码,是典型把代码的实现细节带到了测试代码中,完全是本末倒置的。当然这个测试代码运行的时候,结果都是正确的。那是因为用 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 < 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

所对应的测试代码如下:

复制代码
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 的使用

有了这个工厂,可以很方便的构造不同的模拟数据来运行测试代码。还是上面的例子,如果要测试absence_at方法,涉及到多个 model:

  • HistoryRecord:User 的上课记录
  • Calendar:User 的课程表
  • Logging:User 的日志信息

如果不用 factory-girl 构造测试数据,我们将不得不在 fixture 构造这些测试数据。在 fixture 构造的数据无法指定是那个测试用例使用,但是如果用 Factory 的话,可以为这个方法专门指定一组测试数据。

复制代码
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

这个测试数据的构造工厂,可以放在 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
{1}

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 => 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

至此,准备工作已经就绪,可以在 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 => “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 => "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 => @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 的测试

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=?]",new_account_path)
response.should have_tag("a[href=?]",new_session_path)
end
end
end

总结展望

在写测试代码的时候,并不一定要事无巨细,有些比较简单的方法以及 Rails 的内部的方法,如 named_scope,就完全没有必要测试。本文中,只介绍了用 rspec 写单元测试的代码,对于集成测试没有涉及,这也是今后努力的一个方向。

另外,用 cumumber + rspec + webrat 的 BDD 开发模式也是相当不错的。尤其是 cumumber 对需求的描述,完全可以用它来做需求分析。

关于作者

李冠德, idapted 系统开发组负责人,多年 Java/.net 开发经验,2008 年以来专注于用 Ruby/Rails 为用户打造最好的在线学习平台。


注:本文是 idapted 公司 Rails 系列技术文章的第二篇,第一篇为《Rails 系统重构:从单一复杂系统到多个小应用集群》

2011-06-18 00:005392

评论

发布
暂无评论
发现更多内容

第一章学习总结

Kalman

产品经理 产品经理训练营

用户增长产品经理招聘帖

梁媛

产品经理

产品经理调研备忘录(何先生的梦呓)

小蜜蜂

产品经理 能力模型 产品经理训练营 何先生的梦呓 数据能力

可恶的爬虫直接把生产机器全部爬挂了!

root

Java 爬虫 布隆过滤器 反爬

在线教育产品经理 & 物流行业的产品经理

哈撒啦岛

产品经理 产品经理训练营

如何给产品团队更好地提出设计反馈

Justin

心理学 产品设计 团队协作 28天写作

如果公司要招⼀个⾼级版你

向日葵

产品经理训练营

产品经理训练营_Chapter1

芃芃

产品经理训练营

产品经理训练营 第一周作业记录

周玲

产品经理训练营 岗位要求

产品经理岗位需求总结

Geek_a32093

HTML(三)——在网页中使用图像img

程序员的时光

程序员 28天写作

0互联网工作经验的我,面对字节跳动产品岗1723个招聘岗位慌了起来….

Geek_fe4aa7

产品经理训练营 极客大学产品经理训练营

产品训练营-作业1

简小一

产品经理岗位对比分析

DwToretto

第一章作业:认识产品经理

隋泽

产品经理训练营

第一节课总结

Jove

Week1:按图索骥—从JD入手做能力建设

Y.

产品 产品经理训练营 极客大学产品经理训练营 求职岗位要求

网络安全产品经理任职要求

让时间说真话

产品经理 网络安全产品经理

把我自己做成产品交付给目标岗位

havaguday

产品经理岗位对比

思亭

ACC是如何实现的(28天写作 Day12/28)

mtfelix

自动驾驶 28天写作

对产品岗位的研究和一些看法

Dylan Zhu

甲方日常 87

句子

工作 随笔杂谈 日常

产品经理第一周第二课总结(第一课手贱删除了,回头再补)

克比

极客大学产品训练营作业(第1周)

朱航

Dubbo源码解析-开始篇

冰三郎

Java 分布式 dubbo RPC

assignment 01

Jove

28天瞎写的第二百二十三天:哎哟,我这爆脾气!

树上

28天写作

产品训练营作业1-李沂秾

克比

行业产品经理岗位分析

Shine

产品

HDFS杂谈:数据读写原理

罗小龙

hadoop hdfs 28天写作

如何进行高效的Rails单元测试_Ruby_李冠德_InfoQ精选文章