写点什么

如何进行高效的 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:005373

评论

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

当三位神话人物,穿越到智能视频新视界……

白洞计划

AI 音视频

1017关键词 | Nvidia模型超越GPT-4 | 全模态框架发布 | ChatGPT访问量超必应

言寡意多

用Python激发文案创作灵感:文心一言API的智能应用

幂简集成

AI API 文心一言

Js数组&高阶函数

不在线第一只蜗牛

JavaScript 前端

淘宝天猫商品评论数据接口 —— 电商决策的宝贵资源

tbapi

淘宝API接口 淘宝商品评论数据接口 天猫商品评论数据接口

5大提升工作效率的桌面软件,深度评测!

秃头小帅oi

澜舟科技新突破:大模型实现“持续学习”,应用成本大幅降低

澜舟孟子开源社区

人工智能 持续学习 企业服务

复旦大学全球供应链研究中心揭牌,合合信息共话大数据赋能

合合技术团队

大数据‘’

传奇服务器遭遇袭击,广大玩家需密切关注

网络安全服务

服务器 DDoS 带宽 传奇

IT行业还有未来吗?

程序员高级码农

程序员 互联网 计算机 #编程

理解 Spring Boot

我爱娃哈哈😍

微服务 spring-boot

如何利用海外服务器推广国际业务?

Ogcloud

云服务器 服务器租用 海外服务器 海外高防服务器 海外云服务器

一文搞懂应用架构的3个核心概念

快乐非自愿限量之名

架构 开发

如何让数据清洗工作变得简单

RestCloud

数据同步 ETL 数据清洗 数据集成平台

一个挑战与万种答案:OPPO智慧服务的时代之桥

脑极体

AI

做效能度量遇到领导质疑怎么办?

思码逸研发效能

DevOps 研发效能 效能度量 研发管理软件

C# 并发控制框架:单线程环境下实现每秒百万级调度

快乐非自愿限量之名

C# 前端框架

快速开发体育直播平台教程,源码助你一天内上线运营!

软件开发-梦幻运营部

【直播预约】下周四大咖云集,不见不散!运维生态直播之“可观测技术实践”等你来~

乘云数字DataBuff

可观测性 zabbix oceanbase 应用性能监控 一体化可观测平台Databuff

Spring Boot 的执行器是什么?

我爱娃哈哈😍

微服务 执行器 spring-boot

低代码突破:工业领域应用的潜力与难题解析!

不在线第一只蜗牛

低代码

干货分享|工作8年,我的职场成长笔记

京东零售技术

技术成长

StarRocks Lakehouse 快速入门——Hive Catalog

StarRocks

数据库 hive LakeHouse

Redis 常用指令详解

陆通

Databend 产品月报(2024年9月)

Databend

全球CDN加速的优势与作用

HUODUNYUN

CDN CDN加速 CDN技术 CDN网络加速 全球CDN

怎么提升国外服务器访问速度?实用技巧分享

Ogcloud

网络加速 国外服务器 服务器加速

计划建设数据中台前,这些问题要提前考虑

Aloudata

数据中台 数据仓库 数据虚拟化 noetl

专业对比:Project项目管理系统国内外8款热门工具

爱吃小舅的鱼

实操上手TinyEngine低代码引擎插件化开发

OpenTiny社区

开源 前端 插件化 OpenTiny 低代码引擎

2023开年力作!《流程挖掘白皮书》重磅发布

望繁信科技

数字化转型 流程挖掘 流程资产 流程智能 望繁信科技

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