【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

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

评论

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

TiDB 赋权问题

TiDB 社区干货传送门

故障排查/诊断

前端食堂技术周刊第 44 期:Bun、Vue.js 挑战、React 状态管理的新浪潮、Can I DevTools、函数式编程

童欧巴

Vue React Bun

TiDB+FLINK 实时计算

TiDB 社区干货传送门

实践案例

Tidb灾难恢复演练-多副本丢失

TiDB 社区干货传送门

故障排查/诊断

TiDB 记录日志原理解读

TiDB 社区干货传送门

TiDB 底层架构

排序算法总结

乌龟哥哥

7月月更

PD 关于ID分配的源码分析

TiDB 社区干货传送门

TiDB 底层架构

TiDB升级5.0.2有惊喜

TiDB 社区干货传送门

版本测评

PD 关于tso 分配源代码分析

TiDB 社区干货传送门

TiDB 底层架构

TiDB 目录结构分析

TiDB 社区干货传送门

实践案例

TiFlink: 使用 TiKV 和 Flink 实现强一致的物化视图

TiDB 社区干货传送门

实践案例 TiDB 底层架构

TiDB GC 之原理浅析

TiDB 社区干货传送门

TiDB 底层架构

Spring IOC II

武师叔

7月月更

Spring&SpringBoot 源码笔记整理 |Bean 的加载流程一

自由

Spring5源码解析 7月月更

悲观事务加锁验证

TiDB 社区干货传送门

管理与运维

【SOP 系列】TiDB 使用 SOP 最全合集

TiDB 社区干货传送门

TiDB 底层架构

TiDB 4.0 新特性也太爽了吧

TiDB 社区干货传送门

版本测评

从一个简单的Delete删数据场景谈TiDB数据库开发规范的重要性

TiDB 社区干货传送门

故障排查/诊断

TSO 时间戳转换为自然时间

TiDB 社区干货传送门

实践案例

TiDB系统调参实战经验

TiDB 社区干货传送门

性能调优 实践案例

SpringBoot核心应用第一弹

Java学术趴

7月月更

价值几十万的 TiDB优化

TiDB 社区干货传送门

实践案例

TiDB 在网易游戏的应用实践

TiDB 社区干货传送门

实践案例

DELETE Statement,懂你不容易

TiDB 社区干货传送门

TiDB 底层架构

【TiDB 最佳实践系列】如何高效利用 Grafana 监控分析 TiDB 指标?

TiDB 社区干货传送门

监控

一个联合索引使用问题以及优化方案

TiDB 社区干货传送门

管理与运维 故障排查/诊断

TiDB 5.1 发版,打造更流畅的企业级数据库体验

TiDB 社区干货传送门

新版本/特性发布

记一次TiDB优化

TiDB 社区干货传送门

性能调优

当大数据架构遇上 TiDB

TiDB 社区干货传送门

实践案例

一条 like 条件的慢 SQL 语句优化

TiDB 社区干货传送门

管理与运维

TIDB 入门运维基础视频教程(一)-- 快速体验

TiDB 社区干货传送门

安装 & 部署

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