自动化测试

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

课程投影片

录影

以下的现场课程录影,讲述了投影片 RSpec & TDD Tutorial 和 RSpec Mocks 的内容。

前言

软件测试可以从不同层面去切入,其中最小的测试粒度叫做Unit Test单元测试,会对个别的类别和方法测试结果如预期。再大一点的粒度称作Integration Test整合测试,测试多个元件之间的互动正确。最大的粒度则是Acceptance Test验收测试,从用户观点来测试整个软件。

其中测试粒度小的单元测试,通常会由开发者自行负责测试,因为只有你自己清楚每个类别和方法的内部结构是怎么设计的。而粒度大的验收测试,则常由专门的测试工程师来负责,测试者不需要知道程式码内部是怎么实作的,只需知道什么是系统应该做的事即可。

本章的内容,就是关于我们如何撰写自动化的测试程式,也就是写程式去测试程式。很多人对于自动化测试的印象可能是:

  • 布署前作一次手动测试就够了,不需要自动化
  • 写测试很无聊
  • 测试很难写
  • 写测试不好玩
  • 我们没有时间写测试

时程紧迫预算吃紧,哪来的时间做自动化测试呢?这个想法是相当短视和业馀的想法,写测试有以下好处:

  • 正确(Correctness):确认你写的程式的正确,结果如你所预期。一旦写好测试程式,很容易就可以检查程式有没有写对,大大减少自行除错的时间。
  • 稳定(Stability):之后新加功能或改写重构时,不会影响搞烂之前写好的功能。这又叫作「回归测试」,你不需要手动再去测其他部分的测试,你可以用之前写好的测试程式。如果你的软件不是那种跑一次就丢掉的程式,而是需要长期维护的产品,那就一定有回归测试的需求。
  • 设计(Design):可以采用TDD开发方式,先写测试再实作。这是写测试的最佳时机点,实作的目的就是为了通过测试。从使用API的呼叫者的角度去看待程式,可以更关注在接口而设计出更好用的API
  • 文件(Documentation):测试就是一种程式规格,程式的规格就是满足测试条件。这也是为什么RSpec称为Spec的原因。不知道API怎么呼叫使用时,可以透过读测试程式知道怎么使用。

其中光是第一个好处,就值得你学习如何写测试,来加速你的开发,怎么说呢?回想你平常是怎么确认你写的程式正确的呢? 是不是在命令列中实际执行看看,或是打开浏览器看看结果,每次修改,就重新手动重新整理看看。这些步骤其实可以透过用自动化测试取代,大大节省手工测试的时间。这其实是一种投资,如果是简单的程式,也许你手动执行一次就写对了,但是如果是复杂的程式,往往第一次不会写对,你会浪费很多时间在检查到底你写的程式的正确性,而写测试就可以大大的节省这些时间。更不用说你明天,下个礼拜或下个月需要再确认其他程式有没有副作用影响的时候,你有一组测试程式可以大大节省手动检查的时间。

那要怎么进行自动化测试呢?几乎每种语言都有一套叫做xUnit测试框架的测试工具,它的标准流程是 1. (Setup) 设定测试资料 2. (Exercise) 执行要测试的方法 3. (Verify) 检查结果是否正确 4. (Teardown) 清理还原资料,例如数据库,好让多个测试不会互相影响。

我们将使用RSpec来取代Rails默认的Test::Unit来做为我们测试的工具。RSpec是一套改良版的xUnit测试框架,非常风行于Rails社群。让我们先来简单比较看看它们的语法差异:

这是一个Test::Unit范例,其中一个test__开头的方法,就是一个单元测试,里面的_assert_equal方法会进行验证。个别的单元测试应该是独立不会互相影响的:

  1. class OrderTest < Test::Unit::TestCase
  2. def setup
  3. @order = Order.new
  4. end
  5. def test_order_status_when_initialized
  6. assert_equal @order.status, "New"
  7. end
  8. def test_order_amount_when_initialized
  9. assert_equal @order.amount, 0
  10. end
  11. end

以下是用RSpec语法改写,其中的一个it区块,就是一个单元测试,里面的expect方法会进行验证。在RSpec里,我们又把一个小单元测试叫做example

  1. describe Order do
  2. before do
  3. @order = Order.new
  4. end
  5. context "when initialized" do
  6. it "should have default status is New" do
  7. expect(@order.status).to eq("New")
  8. end
  9. it "should have default amount is 0" do
  10. expect(@order.amount).to eq(0)
  11. end
  12. end
  13. end

RSpec程式码比起来更容易阅读,也更像是一种规格Spec文件,且让我们继续介绍下去。

RSpec简介

RSpec是一套Ruby的测试DSL(Domain-specific language)框架,它的程式比Test::Unit更好读,写的人更容易描述测试目的,可以说是一种可执行的规格文件。也非常多的Ruby on Rails专案采用RSpec作为测试框架。它又称为一种BDD(Behavior-driven development)测试框架,相较于TDDtest思维,测试程式的结果。BDD强调的是用spec思维,描述程式应该有什么行为。

安装RSpec与RSpec-Rails

Gemfile中加入:

  1. group :test, :development do
  2. gem "rspec-rails"
  3. end

安装:

  1. rails generate rspec:install

这样就会建立出spec目录来放测试程式,本来的test目录就用不着了。

以下指令会执行所有放在spec目录下的测试程式:

  1. bin/rake spec

如果要测试单一档案,可以这样:

  1. bundle exec rspec spec/models/user_spec.rb

语法介绍

在示范怎么在Rails中写单元测试前,让我们先介绍一些基本的RSpec用法:

describe和context

describecontext帮助你组织分类,都是可以任意套叠的。它的参数可以是一个类别,或是一个字串描述:

  1. describe Order do
  2. describe "#amount" do
  3. context "when user is vip" do
  4. # ...
  5. end
  6. context "when user is not vip" do
  7. # ...
  8. end
  9. end
  10. end

通常最外层是我们想要测试的类别,然后下一层是哪一个方法,然后是不同的情境。

it和expect

每个it就是一小段测试,在里面我们会用expect(…).to来设定期望,例如:

  1. describe Order do
  2. describe "#amount" do
  3. context "when user is vip" do
  4. it "should discount five percent if total >= 1000" do
  5. user = User.new( :is_vip => true )
  6. order = Order.new( :user => user, :total => 2000 )
  7. expect(order.amount).to eq(1900)
  8. end
  9. it "should discount ten percent if total >= 10000" { ... }
  10. end
  11. context "when user is vip" { ... }
  12. end
  13. end

除了expect(…).to,也有相反地expect(…).not_to可以用。

before和after

如同xUnit框架的setupteardown

  • before(:each) 每段it之前执行,默认写 before 就是 before(:each)
  • before(:all) 整段describe前只执行一次
  • after(:each) 每段it之后执行
  • after(:all) 整段describe后只执行一次

范例如下:

  1. describe Order do
  2. describe "#amount" do
  3. context "when user is vip" do
  4. before(:each) do
  5. @user = User.new( :is_vip => true )
  6. @order = Order.new( :user => @user )
  7. end
  8. it "should discount five percent if total >= 1000" do
  9. @order.total = 2000
  10. expect(@order.amount).to eq(1900)
  11. end
  12. it "should discount ten percent if total >= 10000" do
  13. @order.total = 10000
  14. expect(@order.amount).to eq(9000)
  15. end
  16. end
  17. context "when user is vip" { ... }
  18. end
  19. end

let 和 let!

let可以用来简化上述的before用法,并且支援lazy evaluationmemoized,也就是有需要才初始,并且不同单元测试之间,只会初始化一次,可以增加测试执行效率:

  1. describe Order do
  2. describe "#amount" do
  3. context "when user is vip" do
  4. let(:user) { User.new( :is_vip => true ) }
  5. let(:order) { Order.new( :user => @user ) }
  6. end
  7. end
  8. end

透过let用法,可以比before更清楚看到谁是测试的主角,也不需要本来的@了。

let!则会在测试一开始就先初始一次,而不是lazy evaluation

pending

你可以先列出来预计要写的测试,或是暂时不要跑的测试,以下都会被归类成pending

  1. describe Order do
  2. describe "#paid?" do
  3. it "should be false if status is new"
  4. xit "should be true if status is paid or shipping" do
  5. # this test will not be executed
  6. end
  7. end
  8. end

specify 和 example

specifyexample都是it方法的同义字。

Matcher

上述的expect(…).to后面可以接各种Matcher,除了已经介绍过的eq之外,在 https://www.relishapp.com/rspec/rspec-expectations/docs/built-in-matchers 官方文件上可以看到更多用法。例如验证会丢出例外:

  1. expect { ... }.to raise_error
  2. expect { ... }.to raise_error(ErrorClass)
  3. expect { ... }.to raise_error("message")
  4. expect { ... }.to raise_error(ErrorClass, "message")

不过别担心,一开始先学会用eq就很够用了,其他的Matchers可以之后边看边学,学一招是一招。再进阶一点你可以自己写MatcherRSpec有提供扩充的DSL

Rails中的测试

Rails中,RSpec分成数种不同测试,分别是Model测试、Controller测试、View测试、Helper测试、RouteRequest测试。

安装 Rspec-Rails

Gemfile中加上

  1. gem 'rspec-rails', :group => [:development, :test]

执行以下指令:

  1. $ bundle
  2. $ rails g rspec:install

装了rspec-rails之后,rails g model 或 controller 时就会顺道建立对应的Spec档案了。

如何处理Fixture

Rails内建有Fixture功能可以建立假资料,方法是为每个Model使用一份YAML资料。Fixture的缺点是它是直接插入资料进数据库而不使用ActiveRecord,对于复杂的Model资料建构或关连,会比较麻烦。因此推荐使用FactoryGirl这套工具,相较于Fixture的缺点是建构速度较慢,因此撰写时最好能注意不要浪费时间在产生没有用到的假资料。甚至有些资料其实不需要存到数据库就可以进行单元测试了。

关于测试资料最重要的一点是,记得确认每个测试案例之间的测试资料需要清除,Rails默认是用关联式数据库的Transaction功能,所以每次之间增修的资料都会清除。但是如果你的数据库不支援(例如MySQLMyISAM格式就不支援)或是用如MongoDBNoSQL,那么就要自己处理,推荐可以试试Database Cleaner这套工具。

Capybara简介

RSpec除了可以拿来写单元程式,我们也可以把测试的层级拉高做整合性测试,以Web应用程式来说,就是去自动化浏览器的操作,实际去向网站服务器请求,然后验证出来的HTML是正确的输出。

Capybara就是一套可以搭配的工具,用来模拟浏览器行为。使用范例如下:

  1. describe "the signup process", :type => :request do
  2. it "signs me in" do
  3. within("#session") do
  4. fill_in 'Login', :with => 'user@example.com'
  5. fill_in 'Password', :with => 'password'
  6. end
  7. click_link 'Sign in'
  8. end
  9. end

默认的 Capybara 是不会执行网页上的 JavaScript 的,如果需要测试JavaScriptAjax接口,可以安装额外安装 JavaScript Driver,但是缺点是测试会更耗时间,

其他可以搭配测试工具

Guard是一种Continuous Testing的工具。程式一修改完存盘,自动跑对应的测试。可以大大节省时间,立即回馈。

Shoulda提供了更多Rails的专属Matchers

SimpleCov用来测试涵盖度,也就是告诉你哪些程式没有测试到。有些团队会追求100%涵盖率是很好,不过要记得Coverage只是手段,不是测试的目的。

CI server

CI(Continuous Integration)服务器的用处是每次有人Commit就会自动执行编译及测试(Ruby不用编译,所以主要的用处是跑测试),并回报结果,如果有人送交的程式搞砸了回归测试,马上就有回馈可以知道。推荐第三方的服务包括:

如果自己架设的话,推荐老牌的Jenkins

更多线上资源