写点什么

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

评论

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

学生管理系统架构设计

随欣所遇

架构实战营

架构实战营:模块八作业

Geek_93ffb0

「架构实战营」

Flink State - Backend Improvements and Evolution in 2021

Apache Flink

大数据 flink 开源 编程 实时计算

云原生小课堂 | Envoy请求流程源码解析(一):流量劫持

York

云原生 istio envoy

Trisk:在 Flink 上实现以 task 为中心的流处理动态 Reconfiguration 的 Control Plane

Apache Flink

大数据 flink 开源 编程 实时计算

用实例带你深入理解Java内存模型

华为云开发者联盟

Java JVM JMM 线程安全 Java内存模型

外包学生管理系统架构设计

炎彬

「架构实战营」

1月云短信报告出炉,华为云跃居榜首

博睿数据

六年老员工的幸福感

万事ONES

模块三:学生管理系统详细架构设计

刘璐

小熊派:用OpenHarmory3.0点亮LED

华为云开发者联盟

小熊派 OpenHarmony 驱动开发 小熊派Micro LED

Flink 实践教程-进阶(8):自定义标量函数(UDF)

腾讯云大数据

Linux中buff-cache占用过高解决方案

入门小站

Linux

测试环境与路由 | 阿里巴巴DevOps实践指南

阿里云云效

云计算 阿里云 运维 云原生 测试

如何思考需求的优先级?

石云升

产品经理 需求分析 2月月更 需求排序

针对 Kubernetes v1.22,阿里云容器服务 ACK 提供了哪些升级和增强能力?

阿里巴巴云原生

阿里云 容器 云原生 产品升级 ACK

前后端分离项目,如何解决跨域问题?

CRMEB

基于外包学生管理系统的架构文档

刘帅

如何合理使用 CPU 管理策略,提升容器性能?

阿里巴巴云原生

阿里云 容器 云原生 资源管理 ACK

3月2日,阿里云开源 PolarDB 企业级架构将迎来重磅发布

阿里云数据库开源

数据库 阿里云 开源 分布式 polarDB

学生管理系统详细架构方案

IT屠狗辈

架构实战营 详细架构

安全专属的移动数字化平台WorkPlus加速国企数字化转型

WorkPlus

J2PaaS企业级低代码平台,如何支撑开发企业级应用?

J2PaaS低代码平台

低代码 低代码开发 企业级低代码平台 企业级应用

通过5个函数带你理解K8s DeltaFIFO

华为云开发者联盟

k8s Queue Client-go DeltaFIFO FIFO

在线JWT Token解析解码

入门小站

工具

【web安全】你的open_basedir安全吗?

H

网络安全 WEB安全

详解近端策略优化

行者AI

深度强化学习

网络安全kali渗透学习 web渗透入门Metasploitable2靶机系统介绍

学神来啦

一块屏幕的全球研发之旅

万事ONES

ONES 案例分析

16 张图解带你掌握一致性哈希算法

华为云开发者联盟

负载均衡 分布式系统 一致性哈希 哈希算法 数据迁移

重磅!博睿数据发布新一代统一告警平台

博睿数据

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