1. Rails 应用测试策略
根据我个人的经验, 对于 Rails 应用需要做两方面的测试:
单元测试 : 针对 models, controllers, helpers, mailers 和 lib 下的代码.
其中对 controllers 的测试不是从集成测试的角度, 而是从 是否可以访问到某些 action
和 action 返回的 JSON 等数据是否正确 两个角度进行测试.
单元测试属于 白盒测试 .
单元测试的代码位于 spec 文件夹下的同名的子文件夹, 例如 spec/models .
验收测试 : 也叫 集成测试 , 是指模拟用户在浏览器的操作, 对网站的功能进行测试.
验收测试以功能模块为单位进行测试, 例如用户注册, 添加文章等.
验收测试属于 黑盒测试 .
验收测试的代码位于 spec/requests 文件夹下. (注: Cucumber 更适合用来做验收测试,
请参见续篇: 在 Rails 应用中使用 Cucumber 进行验收测试 )
2. 测试 rails 应用用到的工具
针对版本: rails 3.2, ruby 1.9.3
rspec : BDD 测试框架, 替代 Rails 默认的 TestUnit.
factory_girl : 用于方便的创建测试数据, Rails Test Fixture 的替代品.
shoulda-matchers : 提供一些方便的测试 rails 应用的验证语句.
capybara : 验收测试框架, 用于模拟用户操作测试网站功能.
capybara-webkit : 为 capybara 提供 headless driver, 即在测试时不需要浏览器, 以便在 Linux 持续集成服务器上执行测试.
launchy : 在使用 capybara 测试时通过 save_and_open_save 方法来在浏览器打开当时状态的页面.
Database Cleaner : 用于在测试时清理数据库, 因为 capybara 不支持 rspec 默认的 transactional fixtures.
simplecov : 用于生成测试覆盖率报告.
zeus : 测试辅助工具, 用于加快执行测试和其他 Rails 命名的启动速度.
3. 配置及使用
注: 本文中使用的演示 rails 应用 (my-app) 的源代码存放在 github上 ,
以便随时参考或下载.
3.1 创建 rails 应用
shell 1
2
rails new my-app --skip-test-unit --skip-bundle
cd my-app
3.2 配置 Gemfile
添加以下内容到 Gemfile
Gemfile 完整文件 1
2
3
4
5
6
7
8
9
10
11
12
13
group :test , :development do
gem 'rspec-rails' , '~> 2.0'
end
group :test do
gem 'factory_girl_rails'
gem 'shoulda-matchers'
gem 'capybara'
gem 'capybara-webkit'
gem 'launchy'
gem 'database_cleaner'
gem 'simplecov' , require : false
end
如果你觉得官方的 rubygems 比较慢, 可以使用淘宝的 rubygems 源, 即把 Gemfile 第一行改为:
Gemfile 完整文件 1
source 'http://ruby.taobao.org/'
运行命令安装 gems:
shell
3.3 配置 rspec
运行命令初始化 rspec:
shell 1
rails generate rspec:install
这个命令会创建两个文件: .spec 保存运行 rspec 时的需要使用的选项;
spec/spec_helper.rb 是 rspec 的加载和配置文件, 所有的测试文件都需要 require
这个文件.
3.4 一个简单的测试
下面我们来写一个简单的单元测试. 假设我们需要实现一个方法用于产生指定长度的随机字符串,
我们采用 TDD 的方式, 即先写测试, 通过测试来 分析实现的方法 并 保证代码的正确性 .
单元测试文件的命名规则 是: 位于 spec 父文件夹下, 子文件夹的组织与要测试的文件一致,
测试文件的名字是 xxx_spec.rb (假设要测试的文件名是 xxx.rb ).
我们给这个方法命名为 random_string, 并定义在 MyApp module 的 Utility 类中,
也就是说, 用 MyApp::Utility.random_string 来调用这个方法. 由于这个类属于我们自己的类库,
所以会放在 lib 文件夹下, 于是测试文件就应该是 spec/lib/my_app/utility_spec.rb .
我们先创建这个测试文件及它需要的文件夹结构 (本文中的命令针对于 Linux / Mac OS X 系统):
shell 1
2
mkdir -p spec/lib/my_app
touch spec/lib/my_app/utility_spec.rb
然后用你喜欢的编辑器打开这个测试文件, 输入:
spec/lib/my_app/utility_spec.rb 完整文件 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# encoding: utf-8
require 'spec_helper'
describe MyApp :: Utility , '#random_string' do
context '参数正确' do
it '应该生成长度正确的字符串' do
MyApp :: Utility . random_string ( 0 ) . should == ''
MyApp :: Utility . random_string ( 1 ) . length . should == 1
MyApp :: Utility . random_string ( 10 ) . length . should == 10
MyApp :: Utility . random_string ( 100 ) . length . should == 100
end
it '多次生成的随机字符串应该不同' do
MyApp :: Utility . random_string ( 10 ) . should_not == MyApp :: Utility . random_string ( 10 )
# 或者这样测, 如果每次生成同样的字符串, results 数组 uniq 之后就会只有一个元素
results = 3 . times . map { MyApp :: Utility . random_string ( 4 ) }
results . uniq . length . should_not == 1
end
it '生成的字符串只包含大小写字母和数字' do
10 . times do
MyApp :: Utility . random_string ( 10 ) . should =~ /\A[A-Za-z0-9]*\Z/
end
end
end
context '参数非法 - 指定长度为负数' do
it '应该报错' do
expect { MyApp :: Utility . random_string ( - 1 ) } . to raise_error
expect { MyApp :: Utility . random_string ( - 10 ) } . to raise_error
end
end
end
由于 Rails 不会自动加载 lib 下的文件, 所以我们需要打开 config/application.rb
并找到 config.autoload_paths 这一行, 改为:
config/application.rb 完整文件 1
config . autoload_paths += %W( #{ config . root } /lib)
然后执行测试. 虽然我们知道测试一定会失败, 因为我们根本还没有编写实现这个方法的文件,
但是 看着测试失败 也是 TDD 步骤的一部分, 是为了和写好实现方法之后测试通过的情况区分开.
shell 1
rspec spec/lib/my_app/utility_spec.rb
执行结果提示我们 MyApp::Utility 没有定义. 现在我们来写实现. 同样先创建需要的文件和文件夹结构:
shell 1
2
mkdir lib/my_app
touch lib/my_app/utility.rb
然后用编辑器打开文件并输入:
lib/my_app/utility.rb 完整文件 1
2
3
4
5
6
7
8
9
10
module MyApp::Utility
def self . random_string ( _length )
length = _length . to_i
raise "Wrong argument 'length' given: #{ _length . inspect } " if length < 0
chars = ( 'A' . . 'Z' ) . to_a + ( 'a' . . 'z' ) . to_a + ( '0' . . '9' ) . to_a
chars_length = chars . length
length . times . map { chars [ rand ( chars_length ) ] } . join
end
end
再次执行测试, 并且反复进行 修改代码 -> 执行测试 直到测试通过.
rspec 测试判定方法及测试 Rails 应用的写法请参考 rspec 官方文档.
3.5 执行测试的方式
在 Rails 应用执行 rspec 测试有两种方式:
shell 1
2
3
4
rake test # 执行所有测试, 也可以用 rake spec, 或直接用 rake
rake spec:models # 执行 spec/models 下面的测试
rake spec:helpers # 执行 spec/helpers 下面的测试
rake spec:requests # 执行 spec/requests 下面的测试 (验收测试)
第一次执行这个命令时, 有可能会提示要先执行 rake db:migrate.
shell 1
2
3
4
5
6
7
8
9
10
11
12
13
rspec spec # 执行 spec 下面的测试, 即执行所有测试
rspec spec/models # 执行 spec/models 下面的测试
rspec spec/helpers # 执行 spec/helpers 下面的测试
rspec spec/requests # 执行 spec/requests 下面的测试 (验收测试)
rspec spec/lib # 执行 spec/lib 下面的测试
rspec spec/lib/my_app/utility_spec.rb # 只测试一个文件
rspec spec/lib/my_app/utility_spec.rb:7 # 只执行这个文件第7行所在的单个测试 (每个 "it" 块是一个测试)
rspec spec -t type :request # 执行 type 为 request 的测试, 即验收测试
rspec spec -t '~type:request' # 执行 type 不是 request 的测试
rspec spec -f d # 指定测试结果的格式为 documentation (文档格式)
详细的 rspec 使用方法可以执行 rspec --help 查看.
例如前面那个简单的测试, 使用 -f d 选项会将测试中的 context 等信息按非常便于阅读的格式打印出来:
如果想使用这个格式但不想每次执行测试时都手动指定 -f d 选项, 可以添加到 .rspec 文件里, 例如:
.rspec 完整文件
3.6 使用 factory_girl 构造测试数据
Rails 默认的 test fixture 可用来构造测试需要的数据, 但是不够灵活. factory_girl
提供了一个更灵活的解决方案.
为了演示, 假设我们需要有个 model Post (文章), 并且有个方法可以发布批量设置多篇文章,
即将给定数组里的所有文章的 published 属性设置为 true. 首先, 我们创建这个 model:
shell 1
rails g model Post title:string body:text published:boolean
然后执行数据库迁移命令和测试准备命令:
shell 1
2
rake db:migrate
rake db:test:prepare # 在数据库结构发生变化时, 需要执行这句来保证用 rspec 执行测试时不会出错
创建 model 时会自动生成对应的测试文件, 即 spec/models/post_spec.rb . 打开这个文件并编写测试代码:
spec/models/post_spec.rb 完整文件 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# encoding: utf-8
require 'spec_helper'
describe Post do
it { should validate_presence_of ( :title ) }
it { should validate_presence_of ( :body ) }
end
describe Post , '#publish_posts' do
before do
@posts = FactoryGirl . create_list :post , 10 , published : false
end
context '全部发布' do
before { Post . publish_posts ( @posts ) }
it 'should work' do
@posts . all? { | post | post . published } . should be_true
# 重新从数据库查询数据
Post . all . all? { | post | post . published } . should be_true
end
end
context '部分发布' do
before { Post . publish_posts ( @posts . first ( 3 )) }
it 'should work' do
@posts . first ( 3 ) . all? { | post | post . published } . should be_true
Post . find ( @posts . first ( 3 ) . map ( & :id )) . all? { | post | post . published } . should be_true
@posts . last ( 7 ) . all? { | post | ! post . published } . should be_true
Post . find ( @posts . last ( 7 ) . map ( & :id )) . all? { | post | ! post . published } . should be_true
end
end
end
第5到8行是使用 shoulda 提供的测试判定语句来测试 model 的验证定义.
测试文件里使用了 FactoryGirl.create_list 方法来方便的创建多个 post. 为此,
我们需要定义 post 的 factory , 创建 spec/factories 文件夹并在下面新建
posts.rb 文件:
spec/factories/posts.rb 完整文件 1
2
3
4
5
6
7
FactoryGirl . define do
factory :post do
sequence ( :title ) { | n | "Post #{ n } " }
sequence ( :body ) { | n | "This is post #{ n } " }
published false
end
end
这个文件定义了使用 FactoryGirl.create, FactoryGirl.create_list, FactoryGirl.build,
FactoryGirl.build_list 等方法创建 post 时, 采用的默认属性值 (关于 factory_girl
的详细使用方法请参考其官方文档).
然后执行测试并确认测试是失败的:
shell 1
rspec spec/models/post_spec.rb
编写实现, 之后再次运行测试. 实现代码示例:
app/models/post.rb 完整文件 1
2
3
4
5
6
7
8
9
10
11
12
13
class Post < ActiveRecord :: Base
attr_accessible :title , :body , :published
validates :title , :body , presence : true
def self . publish_posts ( posts )
Post . transaction do
posts . each do | post |
post . update_attributes published : true
end
end
end
end
3.7 使用 capybara 编写验收测试
(注: Cucumber 更适合用来做验收测试, 请参见续篇:
在 Rails 应用中使用 Cucumber 进行验收测试 )
关于 capybara 的使用参考自 RailsCasts 257: Request Specs and Capybara .
下面列出配置步骤和一个测试示例.
首先配置 capybara. 打开 spec/spec_helper.rb 文件, 按下面的注释修改:
spec/spec_helper.rb 完整文件 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 在 require 'rspec/autorun' 这行下面添加
require 'capybara/rails'
require 'capybara/rspec'
Capybara . javascript_driver = :webkit
# 下面这行配置由 true 改为 false
config . use_transactional_fixtures = false
# 在文件的最后一个 end 之前, 即 config.order = "random" 下面, 添加下面的代码
config . before ( :suite ) do
DatabaseCleaner . strategy = :truncation
end
config . before ( :each ) do
DatabaseCleaner . start
end
config . after ( :each ) do
DatabaseCleaner . clean
end
下面是一个测试示例, 用来定义及验证文章列表和文章详情页面是否符合要求:
spec/requests/view_post_list_and_single_post_spec.rb 完整文件 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# encoding: utf-8
require 'spec_helper'
feature '查看文章列表和文章详情' do
background do
# 三个已发布的文章: Post 1, Post 2, Post 3
@published_posts = FactoryGirl . create_list :post , 3 , published : true
# 两个未发布的文章: Post 4, Post 5
@unpublished_posts = FactoryGirl . create_list :post , 2 , published : false
visit posts_path
end
scenario '可以看到已发布的文章及链接' do
@published_posts . each do | post |
page . should have_content ( post . title )
end
end
scenario '应该看不到未发布的文章' do
@unpublished_posts . each do | post |
page . should_not have_content ( post . title )
end
end
context '查看文章详情' do
background do
@post = @published_posts . first
click_link @post . title
end
scenario '可以看到文章的标题和内容' do
page . should have_content ( @post . title )
page . should have_content ( @post . body )
end
scenario '可以通过 "返回" 链接回到文章列表' do
click_link '返回'
current_path . should == posts_path
end
end
end
具体实现请参考 项目源码 .
3.8 使用 simplecov 查看测试覆盖率
首先需要配置 simplecov, 在 spec/spec_helper.rb 的第一行之前添加下面的代码:
spec/spec_helper.rb 完整文件 1
2
require 'simplecov'
SimpleCov . start 'rails'
以后在你运行过测试之后, 就可以用浏览器可以打开 coverage/index.html 查看测试覆盖率情况.
例如:
shell 1
2
3
4
5
6
7
rake spec # 运行所有测试
open coverage/index.html # 打开测试覆盖率报告, 此命令适用于 Mac OS X 系统
# Linux 用户可以直接调用浏览器的命令来打开, 例如使用 Chromium 浏览器:
chromium coverage/index.html
# 或者使用 Firefox:
firefox coverage/index.html
如果你使用源码版本控制工具, 例如 git , 别忘了把 coverage 文件夹加入忽略列表:
.gitignore 完整文件
3.9 使用 zeus 加速测试
每次执行测试时都需要启动 rails 环境, 浪费不少时间. zeus 的原理就是启动好一个 rails
环境, 供之后的测试直接使用. 另外 rails console, rake 等命令也可以使用
zeus console, zeus rake 来快速执行.
首先安装并配置 zeus (不需要把 zeus 加到 Gemfile ):
shell 1
2
gem install zeus
zeus init
会创建一个 zeus.json 的配置文件, 可以修改它来配置 zeus, 例如不需要 Cucumber
的话可以去掉 Cucumber 相关的配置.
这样, 在以后的开发过程中, 只要在一个 console 里启动 zeus, 就可以通过快速执行测试等命令了.
启动 zeus: zeus start
使用 zeus 执行测试需要使用 rspec 命令 (rake spec 在我这里会报错). 只要在测试命令前面加
zeus 即可, 例如:
shell 1
2
zeus rspec spec # 执行所有测试
zeus rspec spec/models/post_spec.rb # 执行单个测试文件
目前使用 zeus 执行测试会有一个 产生空白测试覆盖率报告
的 bug, 所以如果需要查看测试覆盖率, 就需要直接运行 rsepc 命令 (不要使用 zeus)
来运行一次测试, 然后查看测试覆盖率即可.
与 zeus 类似的工具有很多, 例如 spork . 在我使用 spork 时, 发现在测试
Active Admin 实现的页面时有问题, 而
zeus 没有问题, 并且 zeus 配置更加简单, 所以改用了 zeus.
续篇: 在 Rails 应用中使用 Cucumber 进行验收测试