加速测试

字数 4431阅读 1779

加速测试的方法

这里所说的“速度”有两层含义。

其一,当然是测试运行所用的时间。我们这个小程序的测试已经开始出现慢的趋势假设测试会随着程序一起增长,那么测试速度就会越来越慢。我们的目标是保持代码的可维护行,但又不破坏RSpec为我们提供的代码可读性。
其二,开发人员怎样快速编写清晰明了的测试。

  • 第一类,rspec的使用技巧
    1. 使用let
    2. 使用驭件(mock)和使用桩件(stub)
    3. 把慢的测试单独提出来
    4. 分类Gem包
  • 第二类,加载rails的环境
    1. database_cleaner
    2. spring
    3. zeus
    4. spork
  • 第三类,不加载rails环境
    1. 修改传统测试

使用let

到目前为止,为测试准备通用数据时,我们使用的方法是在before :each块中定义实例变量。还有一种RSpec用户更乐意选择的是使用let()。
let()有两个好处:

  1. 不用赋值给实例变量就可以缓存值。
  2. 定义的变量是“惰性计算的”,不调用就不会执行赋值操作。

使用驭件和使用桩件

驭件(mock)是用来替代真实对象的测试对象,也被称为“测试替身”(test double)。 驭件有点类似通过Factory Girl生成的对象,但不会改动数据库中的数据。所以速度快一些。

桩件(stub)是对指定对象方法的重写,返回一个预设的值。也就是说,桩件虽是个虚假方法,但调用时会返回一个真实的值供测试使用。桩件经常用来重写方法的默认功能,特别是在频繁操作数据库或网络密集型交互中。

例如:
要创建联系人的驭件,可以使用Factory Girl提供的build_stubbed()方法。
这个方法会生成一个假冒对象,可以响应很多方法,例如firstname lastname 和fullname。不过所生成的对象不会存入数据库。

要为Contact模型的方法创建桩件,可以使用下面这样的代码,

allow(Contact).to receive(:order).with('lastname,firstname').and_return([contact])

这里我们重现了Contact模型的order作用域,传入一个字符串,指定SQL查询结果的排序方式(按照姓和名字排序) 然后指明希望得到的结果,只有一个元素的数组,元素contact可能是在前面创建的。

传统的慢的测试

  describe "GET #show" do
    let(:widget) { create(:widget) }
    it "assigns the required 1 to @1" do
      get :show, id: widget
      expect(assigns(:widget)).to eq widget
    end

    it "renders the :show template "  do
      get :show, id: widget
      expect(response).to render_template :show
    end
  end

bundle exec rspec spec/controllers/widgets_controller_spec.rb --line_number 4
Finished in 0.29162 seconds

改进之后的快的测试

  describe "GET #show more faster" do
    let(:widget){ build_stubbed(:widget, name: 'zhangsan', email: 'zs@126.com') }

    before :each do
      Widget.stub(:persisted?).and_return(true)
      Widget.stub(:order).with('name, email').and_return([widget])
      Widget.stub(:find).with(widget.id.to_s).and_return(widget)
      Widget.stub(:save).and_return(true)
    end

    before :each do
      Widget.stub(:find).with(widget.id.to_s).and_return(widget)
      get :show, id: widget
    end

    it "assigns the requested widget to @widget" do
      expect(assigns(:widget)).to eq widget
    end

    it "renders the :show template" do
      expect(response).to render_template :show
    end
  end

bundle exec rspec spec/controllers/widgets_controller_spec.rb --line_number 18
Finished in 0.06785 seconds

速度提升 76%

分析: 使用let()把一个驭件赋值给widget。然后为Widget 模型和widget实例创建了一些桩件。在控制器中,我们希望能在Widget类和widget实例上调用一些ActiveRecord提供的方法。所以为这些方法创建了桩件,返回的结果和实际的ActiveRecord方法一样。本例中全部的测试数据都由驭件和桩件提供,没有操作数据库,也没有调用Widget模型

这段测试的优点是,比之前的测试更独立了,现在只需要关注控制器的动作,不用担心模型或数据库等,这么做当然也有缺点,独立是付出了代价的,这段测试的代码量增加了不少。

把慢的测试单独提出来

首先,运行全部的测试 使用 bundle exec rspec spec/ -p 得出最慢的几个测试
然后,把慢的测试都都打上 slow: true 的标签,如下

describe WidgetsController, slow: true do
  describe "GET #show" do
        #慢的测试
  end
end

最后, 在spec/spec_helper.rb文件中

RSpec.configure do |config|
    #config.filter_run focus: true
    config.filter_run_excluding slow: true
end

执行,

运行快的测试
bundle exec rspec spec -p

运行慢的测试
bundle exec rspec spec --tag slow -p

分类Gem包

把production、 develop、 test三种环境的Gem包分类加载到
各自的group下,该功能可以提高2%-3%。主要rails启动的时候不需要加载额外的Gem包、从而大幅度提高rails的启动速度。

充分正确使用database_cleaner

为什么要使用database_cleaner?
Rsepc进行测试的时候,如果有一个用例需要创建并保存到数据库中,当再一次进行测试的时候,就会提示该对象已经存在了,创建失败了。所以想要保证每次测试都能正常执行,需要在每次测试用例执行完毕之后将数据库清空。
如:

it { expect {Deal.make!(create_time: Time.now)}.to 
      change{ Deal.count }.from(0).to(1) }

这个测试在运行的时候就会经常出错,所以要使用database_cleaner来清空测试数据库。

database_cleaner 的三种策略
Deletion

This means the database tables are cleaned using a
delete + recreate strategy. In SQL this means using
the DROP TABLE + CREATE TABLE statements. This strategy
would be considered the slowest, since you have to not
only delete the table data, but also the whole
table structure and then recreate it back.
However in case of problems with other methods
this can be considered the safest fallback method.

Truncation

This means the database tables are cleaned using the
SQL TRUNCATE TABLE command. This will simply empty the table
immidiately, without deleting the table structure itself.

Transaction

This means using BEGIN TRANSACTION statements coupled
with ROLLBACK to roll back a sequence of previous
database operations. Think of it as an "undo button"
for databases. I would think this is the most frequently
used cleaning method, and probably the fastest since
changes need not be directly committed to the DB.

我们常用的配置在 spec/spec_helper.rb

Rspec.configure do |config|
    config.use_transactional_fixtures = false
    config.before(:suite) do
       DatabaseCleaner.strategy = :truncation
    end
    config.before(:each) do
      DatabaseCleaner.start
     end
    config.after(:each)  do
       DatabaseCleaner.clean
    end
end

或者

Rspec.configure do |config|
    config.use_transactional_fixtures = false
    config.before(:suite) do
       DatabaseCleaner.strategy = :transaction
       DatabaseCleaner.clean_with(:truncation)
    end
    config.before(:each) do
      DatabaseCleaner.start
     end
    config.after(:each)  do
       DatabaseCleaner.clean
    end
end

spring

安装:
  1. 在Gemfile中安装spring
    gem "spring", group: :development
    gem "spring-commands-rspec"
  2. 然后执行
    bundle install
spring启动图
spring启动图
spring启动图
常用命令:

spring 用来启动spring
spring status 用来表示启动的状态
time spring rspec spec/ 使用spring 来运行测试

测试结果对比

用spring 来测试rspec_test 所需要的时间

Finished in 3.46 seconds
100 examples, 0 failures, 18 pending
Randomized with seed 8738
real 0m5.171s
user 0m0.060s
sys 0m0.016s

不使用spring测试rspec_test所需要的时间

Finished in 3.4 seconds
100 examples, 0 failures, 18 pending
Randomized with seed 285
real 0m5.362s
user 0m2.804s
sys 0m0.212s

使用spring测试速度提升3%

spring 总结

使用起来很方便, 当第一次执行的时候,会自动在后台开启一个 server 用来 fork, 所以基本上和正常使用 rails command 类似就好了。与 Terminal 的 Session 关联, 关闭当前 terminal session 那么这个 session 下的 spring server 都会自动关闭. 同理, 如果在多个 terminal session 中使用 spring rake 则会有多个 spring server...可以使用 spring binstub 将常用命令(generator, console, rake 等等) 生成一个使用 spring 前缀的命令, 用来简化每次的 spring rake , 命令放到了那个 ./bin/ 目录下暂时还不支持 rails destroy 命令。

zeus

安装
  1. gem install zeus

  2. 对于rspec来说,去除spec/spec_helper.rb中的文件
    require 'rspec/autotest'
    require 'rspec/autorun'
    由于spec/spec_helper.rb文件会自动加上上面配置,所以会导致出现重复测试的情况。

zeus启动图
zeus启动图
zeus启动图
常用命令

zeus console 相等于 rails c
zeus server 相等于 rails s
zeus test spec/ 相等于 rspec spec/
zeus generate model omg 相等于rails g model omg

测试结果对比

使用zeus测试,结果为

time zeus test spec/
Finished in 3.26 seconds
99 examples, 0 failures, 18 pending
Randomized with seed 0
real 0m3.600s
user 0m0.048s
sys 0m0.020s

不使用zeus测试,结果为

Finished in 3.4 seconds
100 examples, 0 failures, 18 pending
Randomized with seed 285
real 0m5.362s
user 0m2.804s
sys 0m0.212s

使用zeus 测试速度提升32%

zeus 总结

需要 zeus start 启动一个 zeus server, 为常使用的命令各准备一个(?), 内存占用稍多使用命令使用 zeus [comman] , 不过都有简称例如 zeus generate(alias: g), zeus server(alias: s), 每条命令都得输 zeus , 不过在 bash_profile 里设置个 alias 就好了(例如: z s)所有 terminal 共享这一份 zeus server, 所以 terminal 多的时候不会有 spring 那样开多个 server。

spork

安装
  1. 在Gemfile中添加

    gem 'spork', '~> 1.0rc'
    
  2. 安装gem
    bundle install

  3. 执行
    spork rspec --bootstrap
    该命令会在spec_helper中添加自己的模板代码
    Spork.prefork代码块中的东西,在Spork启动的时候就加载
    Spork.each_run来表示每次运行rspec都会加载

spork 启动图
spork启动
spork启动
常用命令

spork用来启动spork
time bundle exec rspec spec/ --drb 用来测试

测试结果对比

用来测试rspec_test
使用spork
Finished in 3.65 seconds
100 examples, 0 failures, 18 pending
Randomized with seed 46006
real 0m4.437s
user 0m0.628s
sys 0m0.072s

不使用spork的结果
Finished in 3.4 seconds
100 examples, 0 failures, 18 pending
Randomized with seed 285
real 0m5.362s
user 0m2.804s
sys 0m0.212s

使用spork测试速度提升17%

spork总结

使用不加载rails的环境,进行测试的
app/controller/TracksController

class TracksController < ApplicationController
  def index
    signed_in_user
  end

  def new
    @track = Track.new
  end

  def create
    feed = params[:track]["feed"]
    @track = TrackParserService.parse(feed)

    unless @track.valid?
      render :action => 'new'
      return
    end

    @track.save_with_user!(signed_in_user)

    render :action => 'index'
  end

  def destroy
    Track.find(params[:id]).destroy

    @user = User.first
    render :action => 'index'
  end

  private

  def signed_in_user
    # No authentication yet
    @user ||= User.first
  end
end

/spec/units/controller/tracks_controller_spec.rb测试这样写

APP_ROOT = File.expand_path(File.join(File.dirname(__FILE__), "..", "..", ".."))
$: << File.join(APP_ROOT, "app/controllers")

# A test double for ActionController::Base
module ActionController
  class Base
    def self.protect_from_forgery(xx); end
  end
end

class User; end
class Track; end
class TrackParserService; end

require 'application_controller'
require 'tracks_controller'

describe TracksController do
  let(:controller) { TracksController.new }

  specify "index action returns the signed in user" do
    # setup
    user = stub
    User.stub(:first).and_return user

    # execute action under test
    returned_user = controller.index

    # verify
    returned_user.should == user
    controller.instance_variable_get(:@user).should == user
  end

  specify "new action returns an instance of Track" do
    # setup
    track = stub
    Track.stub(:new).and_return track

    # execute action under test
    new_track = controller.new

    # verify
    new_track.should == track
    controller.instance_variable_get(:@track).should == track
  end

  context "when the model is not valid" do
    it "renders action => 'new'" do
      # define a method for params - TracksController is not aware of it
      controller.class.send(:define_method, :params) do
        {:track => "feed"}
      end

      track = stub(:valid? => false)
      TrackParserService.stub(:parse).and_return(track)

      render_hash = {}
      # hang on to the input hash the render method is invoked with
      # I'll use it to very that the render argument is correct
  controller.class.send(:define_method, :render) do |hash_argument|
      render_hash = hash_argument
  end

      controller.create

      # verify the render was called with the right hash
      render_hash.should == { :action => 'new' }
    end
  end
end

测试所需要时间 Finished in 0.00202 seconds 仅需要2毫秒

瀑布开发模式

瀑布开发模式
瀑布开发模式

优点:

  1. 为项目提供了按阶段划分的检查点。
  2. 当前一阶段完成后,您只需要去关注后续阶段。

缺点:

  1. 在项目各个阶段之间极少有反馈。
  2. 只有在项目生命周期的后期才能看到结果。
  3. 通过过多的强制完成日期和里程碑来跟踪各个项目阶段。
  4. 开发阶段出现的bug往往有些在后期测试中查不到,并且可能出现查到之后,修改会非常困难。

TDD介绍

也就是 Test Driven Development--测试驱动开发,其实是一种开发方式的巨大提高。它
提出了一种新的开发方式:以测试为驱动。在此,我仍然想引用一个曾经看过的ThoughtWorks的
一个人的Blog中的一句话:“什么是TDD?TDD就是把你的需求用测试给描述出来。”这句话一下
子让我明白了TDD的意义,并且被我个人奉为TDD中的经典 :)

通俗易懂的概述: TDD就是在写每个方法的时候建一个测试方法,等方法写完之后,运行测试方法来检查运行结果。

TDD周期图
TDD周期图

归根到底,TDD的实质仍然是以需求来驱动开发,只是,TDD中把需求进一步写成了测试,那
成了测试驱动开发了。

这么做的好处是什么?我想至少有以下这么几条:

1、你的代码是可测试的。
2、你的代码完全反应了需求。
3、通过测试驱动,会规范你的代码和结构,甚至架构。

通常对于TDD的误区:

误区一:没什么用处,多此一举

有人会说,我在编写方法的时候本来就是考虑了这些因素的,并且我在调用的时候也加了判断条件。我以前从来不写单元测试,系统“一直按我期望的那样正常运行”。
问题是,你相信你写的代码吗,你敢保证每一个方法,你都这样去思考过吗:它应该返回某个期望的值,如果参数是一些边界值,它应该返回这样而不是让系统崩溃。也许大多数时候,你都是匆匆想一下,马上就写方法名,方法体,你也许考虑了主要的因素,但是是否这个方法能处理你没有预期到的条件。
你是否有这样的感觉,越是软件做到后面,你越不敢保证软件不会出Bug;当别人在一边竖起大拇指称赞系统多么稳定的时候,你的心总是悬空的,你知道它随时都有可能出现问题。
TDD要求在每个方法定义编写前,去考虑方法的各种可能情况,并且直到测试通过,才开始编写下一个方法。它是你在编写最小单元功能的时候,确保每一个功能单元是更加健壮的,因此称作单元测试。
TDD的神奇力量不在于那段测试代码,那只不过是一个普通方法的调用,验证而已。
TDD最宝贵的是:促使你在设计每个最小功能的时候,花一点时间去仔细思考这个最小单元(方法)的各种边界条件,确保每一个单元更加健壮,稳定。这样,到最后,你的整个系统也更加可靠文档。
只有经过测试的代码才是可靠的。虽然Bug不可避免,但是,如果你做了严格的单元测试,你会对你的代码有更多的信心。

误区二:浪费时间

TDD要求在每个方法定义编写之前,先写测试代码,即你要花一点时间去思考这个方法的各种边界条件,调用时会出现的各种情况。
这对于我们平时总是拿到一个功能,就开始定义类写方法相比较,却是是会花点时间。但是如果最终比较,它并不浪费时间。
你是否有这样的感觉,到一个比较大的功能快完成的时候,你会花很多时间去调试。到后面,每一个Bug的调试,都会花费相当大的时间去定位和排错。常常,我们在一大堆断点之间跳来跳去,只是因为某个引用为null。并且断点调试并不是那么顺利的,有时候你需要运行几次才能够定位到bug的地方。幸运的是,也许你凭经验知道大概的位置,这可以范围,但是不可避免的是,你需要花费更多的时间。
而经过单元测试,每一个方法都经过了足够仔细的考虑,这将大大减少后期Bug的频率。原因很简单,你在设计一小块功能的时候,也许考虑得比较仔细,但是当一个单元被整合进一个大的系统,在复杂的系统环境下,你没有考虑到的因素就暴露出来了。并且系统越到后面,问题越多。
自己好好算算,这样的时间你浪费了多少。

TDD的好处

好处一:促进代码规范,设计结构合理,更遵循好的设计原则

刚开始接触单元测试是会遇到挫折的,因为你会发现你编写的方法难以测试。比如参数太依赖另一个方法或者对象,参数不可构造,方法太复杂,功能混乱导致边界条件太多,等等,这些都是不良的设计。
遵循好的设计原则,比如单一职责,方法有清晰单一的任务,比如依赖于接口而不是实现的参数,不仅有助于减小耦合,在测试的时候更容易构造接口实现的参数等等。因此单元测试反过来促进你遵循更好的设计思想。这里引用《ASP.NET MVC实战》里面一句话:“我们极度关注控制器类的测试是因为测试驱动控制器确保它们有良好的设计。对拙劣的代码进行测试驱动几乎是不可能的”。

好处二:精准的定位错误的地方

因为测试的是最小的功能单元,能最小时间代价的获取错误位置和原因。

好处三:减少调试时间

前面我们分析了,在系统后期调试会花费的时间代价

好处四:更健壮,可靠的代码,可以睡好觉

发现,开始TDD之后,我对代码更加有信心,不会时时担心这会出问题你也会出问题,虽然Bug难免,但是经过测试的代码更加可靠,这样是不是能多睡觉,少加班呢,更重要的是减少不少焦虑细胞。

TDD的三条原则

1、You are not allowed to write any production code unless it is to make a failing unit test pass.

除非为了使一个失败的unit test通过,否则不允许编写任何产品代码

2、You are not allowed to write any more of a unit test than is sufficient to fail; and compilation failures are failures.

在一个单元测试中只允许编写刚好能够导致失败的内容(编译错误也算失败)
在unit test中,你不能编写太多的内容,只要一出现该unit test代码不能编译通过,或者断言失败,
就必须停下来开始编写产品代码。

3、You are not allowed to write any more production code than is sufficient to pass the one failing unit test.

只允许编写刚好能够使一个失败的unit test通过的产品代码
你所编写的产品代码应该以刚好能够使得unit test编译通过或者测试通过为准

参考:
书籍:《测试驱动的艺术》
https://github.com/burke/zeus
https://github.com/rails/spring
https://github.com/sporkrb/spork-rails
https://github.com/sporkrb/spork
http://railscasts.com/episodes/285-spork
http://www.adomokos.com/2011/04/running-rails-rspec-tests-without-rails.html

推荐阅读更多精彩内容