Developer testing isn’t primarily about verifying code. It’s about making great code. If you can’t test something, it might be your testing skills failing you but it’s probably your code code’s design. Testable code is almost always better code. - Chad Fowler
软件测试可以从不同层面去切入,其中最小的测试粒度叫做Unit Test单元测试,会对个别的类别和方法测试结果如预期。再大一点的粒度称作Integration Test整合测试,测试多个元件之间的互动正确。最大的粒度则是Acceptance Test验收测试,从用户观点来测试整个软件。
其中测试粒度小的单元测试,通常会由开发者自行负责测试,因为只有你自己清楚每个类别和方法的内部结构是怎么设计的。而粒度大的验收测试,则常由专门的测试工程师来负责,测试者不需要知道程式码内部是怎么实作的,只需知道什么是系统应该做的事即可。
本章的内容,就是关于我们如何撰写自动化的测试程式,也就是写程式去测试程式。很多人对于自动化测试的印象可能是:
时程紧迫预算吃紧,哪来的时间做自动化测试呢?这个想法是相当短视和业余的想法,写测试有以下好处:
其中光是第一个好处,就值得你学习如何写测试,来加速你的开发,怎么说呢?回想你平常是怎么确认你写的程式正确的呢? 是不是在命令列中实际执行看看,或是打开浏览器看看结果,每次修改,就重新手动重新整理看看。这些步骤其实可以透过用自动化测试取代,大大节省手工测试的时间。这其实是一种投资,如果是简单的程式,也许你手动执行一次就写对了,但是如果是复杂的程式,往往第一次不会写对,你会浪费很多时间在检查到底你写的程式的正确性,而写测试就可以大大的节省这些时间。更不用说你明天,下个礼拜或下个月需要再确认其他程式有没有副作用影响的时候,你有一组测试程式可以大大节省手动检查的时间。
那要怎么进行自动化测试呢?几乎每种语言都有一套叫做xUnit测试框架的测试工具,它的标准流程是 1. (Setup) 设定测试资料 2. (Exercise) 执行要测试的方法 3. (Verify) 检查结果是否正确 4. (Teardown) 清理还原资料,例如数据库,好让多个测试不会互相影响。
我们将使用RSpec来取代Rails默认的Test::Unit来做为我们测试的工具。RSpec是一套改良版的xUnit测试框架,非常风行于Rails社群。让我们先来简单比较看看它们的语法差异:
这是一个Test::Unit范例,其中一个test_开头的方法,就是一个单元测试,里面的assert_equal方法会进行验证。个别的单元测试应该是独立不会互相影响的:
class OrderTest < Test::Unit::TestCase
def setup
@order = Order.new
end
def test_order_status_when_initialized
assert_equal @order.status, "New"
end
def test_order_amount_when_initialized
assert_equal @order.amount, 0
end
end
以下是用RSpec语法改写,其中的一个it区块,就是一个单元测试,里面的expect方法会进行验证。在RSpec里,我们又把一个小单元测试叫做example:
describe Order do
before do
@order = Order.new
end
context "when initialized" do
it "should have default status is New" do
expect(@order.status).to eq("New")
end
it "should have default amount is 0" do
expect()@order.amount).to eq(0)
end
end
end
RSpec程式码比起来更容易阅读,也更像是一种规格Spec文件,且让我们继续介绍下去。
RSpec是一套Ruby的测试DSL(Domain-specific language)框架,它的程式比Test::Unit更好读,写的人更容易描述测试目的,可以说是一种可执行的规格文件。也 非常多的Ruby on Rails专案采用RSpec作为测试框架。它又称为一种BDD(Behavior-driven development)测试框架,相较于TDD用test思维,测试程式的结果。BDD强调的是用spec思维,描述程式应该有什么行为。
在Gemfile中加入:
group :test, :development do
gem "rspec-rails"
end
安装:
rails generate rspec:install
这样就会建立出spec目录来放测试程式,本来的test目录就用不着了。
以下指令会执行所有放在spec目录下的测试程式:
bin/rake spec
如果要测试单一档案,可以这样:
bundle exec rspec spec/models/user_spec.rb
在示范怎么在Rails中写单元测试前,让我们先介绍一些基本的RSpec用法:
describe和context帮助你组织分类,都是可以任意套叠的。它的参数可以是一个类别,或是一个字串描述:
describe Order do
describe "#amount" do
context "when user is vip" do
# ...
end
context "when user is not vip" do
# ...
end
end
end
通常最外层是我们想要测试的类别,然后下一层是哪一个方法,然后是不同的情境。
每个it就是一小段测试,在里面我们会用expect(…).to来设定期望,例如:
describe Order do
describe "#amount" do
context "when user is vip" do
it "should discount five percent if total >= 1000" do
user = User.new( :is_vip => true )
order = Order.new( :user => user, :total => 2000 )
expect(order.amount).to eq(1900)
end
it "should discount ten percent if total >= 10000" { ... }
end
context "when user is vip" { ... }
end
end
除了expect(…).to,也有相反地expect(…).not_to可以用。
如同xUnit框架的setup和teardown:
before(:each)
每段it之前执行before(:all)
整段describe前只执行一次after(:each)
每段it之后执行after(:all)
整段describe后只执行一次范例如下:
describe Order do
describe "#amount" do
context "when user is vip" do
before(:each) do
@user = User.new( :is_vip => true )
@order = Order.new( :user => @user )
end
it "should discount five percent if total >= 1000" do
@order.total = 2000
expect(@order.amount).to eq(1900)
end
it "should discount ten percent if total >= 10000" do
@order.total = 10000
expect(@order.amount).to eq(9000)
end
end
context "when user is vip" { ... }
end
end
let可以用来简化上述的before用法,并且支援lazy evaluation和memoized,也就是有需要才初始,并且不同单元测试之间,只会初始化一次,可以增加测试执行效率:
describe Order do
describe "#amount" do
context "when user is vip" do
let(:user) { User.new( :is_vip => true ) }
let(:order) { Order.new( :user => @user ) }
end
end
end
透过let用法,可以比before更清楚看到谁是测试的主角,也不需要本来的@
了。
let!则会在测试一开始就先初始一次,而不是lazy evaluation。
你可以先列出来预计要写的测试,或是暂时不要跑的测试,以下都会被归类成pending:
describe Order do
describe "#paid?" do
it "should be false if status is new"
xit "should be true if status is paid or shipping" do
# this test will not be executed
end
end
end
specify和example都是it方法的同义字。
上述的expect(…).to后面可以接各种Matcher,除了已经介绍过的eq之外,在https://www.relishapp.com/rspec/rspec-expectations/docs/built-in-matchers 官方文件上可以看到更多用法。例如验证会丢出例外:
expect { ... }.to raise_error
expect { ... }.to raise_error(ErrorClass)
expect { ... }.to raise_error("message")
expect { ... }.to raise_error(ErrorClass, "message")
不过别担心,一开始先学会用eq
就很够用了,其他的Matchers可以之后边看边学,学一招是一招。再进阶一点你可以自己写Matcher,RSpec有提供扩充的DSL。
用假的物件替换真正的物件,作为测试之用。主要用途有:
在Rails中,RSpec分成数种不同测试,分别是Model测试、Controller测试、View测试、Helper测试、Route和Request测试。
在Gemfile中加上
gem 'rspec-rails', :group => [:development, :test]
执行以下指令:
$ bundle
$ rails g rspec:install
Rails内建有Fixture功能可以建立假资料,方法是为每个Model使用一份YAML资料。Fixture的缺点是它是直接插入资料进数据库而不使用ActiveRecord,对于复杂的Model资料建构或关连,会比较麻烦。因此推荐使用FactoryGirl这套工具,相较于Fixture的缺点是建构速度较慢,因此撰写时最好能注意不要浪费时间在产生没有用到的假资料。甚至有些资料其实不需要存到数据库就可以进行单元测试了。
关于测试资料最重要的一点是,记得确认每个测试案例之间的测试资料需要清除,Rails默认是用关联式数据库的Transaction功能,所以每次之间增修的资料都会清除。但是如果你的数据库不支援(例如MySQL的MyISAM格式就不支援)或是用如MongoDB的NoSQL,那么就要自己处理,推荐可以试试Database Clener这套工具。
RSpec除了可以拿来写单元程式,我们也可以把测试的层级拉高做整合性测试,以Web应用程式来说,就是去自动化浏览器的操作,实际去向网站服务器请求,然后验证出来的HTML是正确的输出。
Capybara就是一套可以搭配的工具,用来模拟浏览器行为。使用范例如下:
describe "the signup process", :type => :request do
it "signs me in" do
within("#session") do
fill_in 'Login', :with => 'user@example.com'
fill_in 'Password', :with => 'password'
end
click_link 'Sign in'
end
end
如果真的需要打开浏览器测试,例如需要测试JavaScript和Ajax接口,可以使用Selenium或Watir工具。真的打开浏览器测试的缺点是测试比较耗时,你没办法像单元测试一样可以经常执行得到回馈。另外在设定CI server上也较麻烦,你必须另有一台桌面作业系统才能执行。
Guard是一种Continuous Testing的工具。程式一修改完存盘,自动跑对应的测试。可以大大节省时间,立即回馈。
Shoulda提供了更多Rails的专属Matchers
SimpleCov用来测试涵盖度,也就是告诉你哪些程式没有测试到。有些团队会追求100%涵盖率是很好,不过要记得Coverage只是手段,不是测试的目的。
CI(Continuous Integration)服务器的用处是每次有人Commit就会自动执行编译及测试(Ruby不用编译,所以主要的用处是跑测试),并回报结果,如果有人送交的程式搞砸了回归测试,马上就有回馈可以知道。推荐第三方的服务包括:
如果自己架设的话,推荐老牌的Jenkins。