加速测试—讲座版

作者: AQ王浩 | 来源:发表于2014-01-16 23:17 被阅读284次

    加速测试的方法

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

    其一,当然是测试运行所用的时间。我们这个小程序的测试已经开始出现慢的趋势假设测试会随着程序一起增长,那么测试速度就会越来越慢。我们的目标是保持代码的可维护行,但又不破坏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.

    Drop Table + Create Table ,不仅要删除数据,还要删除表结构,最后还得重新创建表,不过这是最彻底最安全的方式。

    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.

    Truncate Table 不删除表结构。

    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.

    Begin Trasaction + Rollback

    那么transaction > truncation , deletion

    http://stackoverflow.com/questions/11419536/postgresql-truncation-speed/11423886#11423886

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

    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%

    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%

    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%

    使用不加载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
    
    
      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往往有些在后期测试中查不到,并且可能出现查到之后,修改会非常困难。
      [关键词,开发阶段bug,代码越写越不放心,测试代码覆盖率,rabl,后期才能看到结果]

    TDD介绍

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

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

    TDD周期图TDD周期图

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

    这么做的好处是什么?我想至少有以下这么几条:
    1、你的代码是可测试的。[关键词,可测试]
    2、你的代码完全反应了需求。[关键词,用测试描述需求,测试驱动代码,代码反应需求]
    3、通过测试驱动,会规范你的代码和结构,甚至架构。[关键词,测试影响架构]

    通常对于TDD的误区:

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

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

    误区二:浪费时间

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

    误区三: 100%的代码覆盖率

    选择覆盖率的标准时,应该考虑所用的技术、语言及开发工具等,通常会因为某些功能没有测到,而是因为语言的设计和API的设计风格使得100%的覆盖率不太现实而已。

    一大早,一个年轻的程序员问大师:
      “我准备写一些单元测试用例。代码覆盖率应该达到多少为好?”
      大师回答道:
      “不要考虑代码覆盖率,只要写出一些好的测试用例即可。”
      年轻的程序员很高兴,鞠躬,离去。
      之后没多久,第二个程序员问了大师同样的问题。
      大师指着一锅烧沸的水说:
      “我应该往这个锅里放多少米?”
      这个程序员看起来被难住了,回答道:
      “我怎么会有答案?这取决于要给多少人吃,他们饿不饿,有什么菜,你有多少米,等等。”
      “完全正确,”大师说。
      第二个程序员很高兴,鞠躬,离去。
      末了,来了第三个程序员问了大师同样的关于代码覆盖率的问题。
      “百分之八十,不能少!”大师一拳锤在桌子上,用严厉的口气回答道。
      第三个程序员很高兴,鞠躬,离去。
      回复完这个之后,一个年轻的实习生走到大师身边:
      “大师,今天我无意中听到了你对同一个代码覆盖率问题给出了三个不同的答案。为什么?”
      大师从椅子上站起来:
      “给我泡点新茶,我们聊聊这个。”
      当杯子里倒满了冒着热气的绿茶后,大师开始说:
      “这第一个程序员是个新手,刚刚开始学测试。目前他有大量的程序都没有测试用例。他有很长的路要走;现在对他要求代码覆盖率只会打击他,没有什么用处。最好是让他慢慢的学会写一些测试用例,测试一下。他可以以后再考虑代码覆盖率。”
      “而这第二个程序员,不论对编程还是测试都是十分的有经验。我以问作答,问她应该往锅里放多少米,使她明白决定测试用例多少的因素有很多,她比我更知道这些因素——毕竟是她自己的代码。对这个问题没有一个简单的、直接的答案。以她的聪明完全能明白这个道理,正确的完成任务。”
      “我明白了,”年轻的实习生说,“但是如果没有一个简单直接的答案,那你为什么告诉第三个程序员‘百分之八十,不能少’呢?”
      大师笑的前仰后合,绿茶都喷了出来。
      “这第三个程序员只想得到一个简单的答案——即使根本没有简单的答案 … 而且即使有答案她也不会按答案做。”
    [关键词,代码覆盖率,在写好测试用例之上,当然是越多越好]

    TDD的好处

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

    刚开始接触单元测试是会遇到挫折的,因为你会发现你编写的方法难以测试。比如参数太依赖另一个方法或者对象,参数不可构造,方法太复杂,功能混乱导致边界条件太多,等等,这些都是不良的设计。
    遵循好的设计原则,比如单一职责,方法有清晰单一的任务,比如依赖于接口而不是实现的参数,不仅有助于减小耦合,在测试的时候更容易构造接口实现的参数等等。因此单元测试反过来促进你遵循更好的设计思想。

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

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

    好处三:减少调试时间

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

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

    发现,开始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编译通过或者测试通过为准 [关键词,切勿写过多的产品代码]

    以TDD为荣,以不写测试为耻。以高测试覆盖率为荣,以低测试覆盖率为耻!

    宣传语

    历经两个半月的准备,三次大改版,十七次小改版。le1024终于要和大家见面了。

    le1024每天推荐1~3段,有趣、有爱、有故事的视频。

    为您工作、学习、生活之余增加一点快乐的感觉。程序员必看的快乐视频网站

    参考:
    书籍:《测试驱动的艺术》
    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

    相关文章

      网友评论

        本文标题:加速测试—讲座版

        本文链接:https://www.haomeiwen.com/subject/dtmctttx.html