zoukankan      html  css  js  c++  java
  • 使用Cucumber+Rspec玩转BDD(7)——测试重构


    ### 温故知新 ###


    在前面的六个章节中,我们循序渐进地完善了一个用户帐号系统,这样的系统一般都会作为一个独立的模块交付。在交付这个模块之前,还需要进一步地做些重构工作。在这篇文章中,笔者将会围绕测试重构展开。

    源码下载:http://github.com/404/bdd_user_demo


    ### 主要内容 ###

        1. 测试环境本地化;
        2. 归类 steps;
        3. 用 Factory_girl 代替 fixtures;
        4. Steps Within Steps;
        5. Helpers


    ### 新建工作分支 ###

    $ git checkout -b refactoring001


    ### 本地化测试环境 ###

    $ gedit lib/tasks/cucumber.rake

    修改第 5 行,

       t.cucumber_opts = "--format pretty --language zh-CN"

    然后运行测试的时候就可以不用指定语言参数了,系统可以自动识别并读取我们用简体中文编写的故事。

    (不过,目前的 Cucumber 中,如果你编写的故事场景名称不是英文,好像不能识别场景名称;所以,在测试单个文件(*.feature)的时候,还需要加上 -l 参数。)

    可以运行下面的命令检测设置是否生效。

    $ rake features




    ### 组织结构良好的测试脚本 ###

    Cucumber 默认会加载 features/step_definitions/ 这个目录中所有的 *_steps.rb,如果读者朋友们稍微留心一点,就会很容易察觉到将一些公用方法放在 user_steps.rb 中算不上明智之举,因为那样的话感觉 user_steps.rb 有些杂乱。

    明智的做法是将这公用方法单独放入一个 *_steps.rb 文件中。

    $ gedit features/step_definitions/page_steps.rb

    从 user_steps.rb 中剪切如下代码并作为 page_steps.rb 的填充,

    When /^我来到(.+)$/ do |page_name|
      visit path_to(page_name)
    end
     
    When /^我在输入框<(.+)>中输入<(.*)>$/ do |field, value|
      fill_in(field, :with => value)
    end
     
    When /^我勾选<(.+)>$/ do |field|
      check(field)
    end
     
    When /^我按下<(.+)>按钮$/ do |button|
      click_button(button)
    end
     
    Then /我应该看到<(.+)>的提示信息/ do |msg|
      response.body.should =~ Regexp.new(msg)
    end


    开发人员应该尽量拆散一些杂乱的测试脚本文件,使得每一个测试脚本文件看起来干干净净,清晰明了。


    ### Factory_girl 初步之装载测试数据 ###

    Factory_girl 是一个绝佳的fixtures替代品。fixtures 即Rails单元测试中内置的测试夹具,用来放一些测试数据;以前用单元测试构建测试数据的时候,都是在 test/fixtures/ 目录中新建YAML文件,并在这些YAML文件中按YAML的语法格式编写测试数据。而用上 Factory_girl 后,你可以直接用Ruby的语法编写测试数据;一是加快了测试速度,二来对程序员的大脑也友好些;而且 Factory_girl 能做的不仅仅是填充一些测试数据,还可以对这些数据进行灵活的变换以适应开发人员的需要。更多 Factory_girl 的信息请查阅该项目在 GitHub 上的主页:http://github.com/thoughtbot/factory_girl

    可以用 gem 命令安装 factory_girl,

    $ gem install thoughtbot-factory_girl --source http://gems.github.com

    然后在你的环境配置文件中绑定这个gem包。由于factory_girl是拿来做测试用,所以将该gem包绑定在测试环境的配置文件中。

    $ gedit config/environments/test.rb

    添加如下代码,

      config.gem "thoughtbot-factory_girl", :lib => "factory_girl", :source => "http://gems.github.com"

    按照文档上所说的,Factory_girl可以自动加载建立在 test/ 和 spec/ 目录中的测试数据。我们不妨在 spec/ 目录中新建一个 factories 目录,并在这个目录中放置所需的测试数据。

    $ mkdir spec/factories

    $ gedit spec/factories/user.rb


    填充如下代码,

    Factory.define :static_user, :class => User do |user|
      user.username              { '404' }
      user.email                 { 'xuliicom@gmail.com' }
      user.password              { 'password' }
      user.password_confirmation { 'password' }
    end


    如上,我们定义了一个 Factory,这个 Factory 的名字叫 :static_user, :static_user 代表的是一个instance;在 Factory.define 的第二个参数中,我们用迭代器的方式构造了一个User 模型类的实例,这个实例可以通过 :static_user 来标识。

    接下来,我们要在测试代码中调用 :static_user 这个 instance 所包含的内容(即测试数据)。

    $ gedit spec/models/user_spec.rb

    找到如下这段代码,

      before(:each) do
        @valid_attributes = {
          :username               => '404',
          :email                  => 'xuliicom@gmail.com',
          :password               => 'password',
          :password_confirmation  => 'password'
        }
        @user = User.new(@valid_attributes)
      end


    将 before(:each) do ... end 这段代码替换如下,

      before(:each) do
        @user = Factory.build(:static_user)
      end


    上面两段代码的效果是一样的。使用第二种方式,我们将测试数据建立在了测试代码之外,并通过一行代码就将测试数据搬到测试脚本中来了,我们可以在任何测试文件中以这样的方式来“搬运”测试数据。显然,读者朋友们已经尝到了使用Factory_girl的第一个甜头,那就是测试数据 “一次定义,多处可用”。上面的代码中,Factory.build 创建了我们在用测试数据填充的 UserModel 实例对象。如果省去 build 方法直接用 Factory(:static_user) 这种形式还会多一个save操作,不过在此我们只需要在测试的时候内容中有这么一个数据就行了,所以才用 build 方法。

    接着,找到如下这段代码,

      it "should have a unique username and password" do
        @first_user = User.create!(@valid_attributes)
        @second_user = User.new(@valid_attributes)
        @second_user.should_not be_valid
        @second_user.should have(1).errors_on(:username)
        @second_user.should have(1).errors_on(:email)
      end


    修改为,

      it "should have a unique username and password" do
        @first_user = Factory.create(:static_user)
        @second_user = @user
        @second_user.should_not be_valid
        @second_user.should have(1).errors_on(:username)
        @second_user.should have(1).errors_on(:email)
      end


    Factory.create() 和 Factory() 方法都会新建记录并执行保存操作,然后返回保存后的实例对象;另外,Factory()方法还可以传入hash参数修改已定义的属性值。在上面修改后的代码中,@first_user 指向的是一个已经保存过的User实例,@second_user试图使用同样的数据新建同样的记录;记得前面我们在 UserModel 类中加入过 username 和 email 必须唯一的验证,那么理论上@second_user的行为应该不会得逞,所以 @second_user.should_not be_valid 及后面两句断言应该能够顺利运行,不妨运行下测试看看。

    $ ruby script/spec spec/models/user_spec.rb



    测试通过!我们再来找找其他的测试代码可以用上 Factory 的地方。

    $ gedit features/step_definitions/user_steps.rb

    找到如下这段代码,

    Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过(且已经激活了帐号)?$/ do |username, email, password, confirm|
      @valid_attributes = {
        :username              => username, 
        :email                 => email, 
        :password              => password, 
        :password_confirmation => password
      }
      @user = User.create!(@valid_attributes)
      if confirm
        @user.activation_token = nil
        @user.save(false)
      end
    end


    修改为,

    Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过(且已经激活了帐号)?$/ do |username, email, password, confirm|
      @user = Factory :static_user,
        :username              => username,
        :email                 => email, 
        :password              => password,
        :password_confirmation => password  

      if confirm
        @user.activation_token = nil
        @user.save(false)
      end
    end


    在上面的那段代码中,我们用测试脚本中的参数结合Factory() 方法修改了 :static_user 这个实例的属性值。

    运行测试,

    $ rake features



    测试通过!其实这段代码我们还可以进行重构,因为 if confirm .. end 中间还有两行代码,而且其中有一行代码还是赋值操作,不仅修改了数据,后一句代码还对修改的数据记录执行了更新操作;这就是说我们在测试脚本中用ActiveRecord的方式操作了数据。那么,既然是跑测试,为了测试脚本的干净利落,我们是不是应该尽量减少使用测试脚本直接地操作数据,转而用定义测试数据的方式实现呢?如果是,那么像这样一个“问题”您能想到好的解决方案吗?暂且留给读者朋友们思考,如果您有好的想法欢迎在下面留言!


    ### Steps Within Steps ###

    正如我们在第6章所说的那样,Cucumber的运行以故事场景为单位,这些故事场景都是彼此独立的;上一个场景的执行结果不会在下一个(或其他)场景中有效。如果要在场景B中用到场景A中的情节步骤,就需要在场景B中重复定义场景A所用的情节(至少在测试代码里边包含针对这一复用情节的相关脚本)。我们在前面编写的故事用例中,大多数场景都包含相同的情节,比如下面这句:

    当 我以<xuliicom@gmail.com/password>这个身份登录

    这个场景子句还是一组复合语句,即 steps within steps 嵌套模式,可以打开 features/step_definitions/user_steps.rb 文件查阅这个子句的具体实现。如下代码,

    When /^我以<(.+)\/(.+)>这个身份登录(并勾选<记住我>)?$/ do |username_or_email, password, remember|
      当 %{我来到用户登录页面}
      而且 %{我在输入框<用户名或邮箱>中输入<#{username_or_email}>}
      而且 %{我在输入框<密码>中输入<#{password}>}
      而且 %{我勾选<记住我>} if remember
      而且 %{我按下<登录>按钮}
    end


    上面这段代码折叠后仅仅一行而已,如果不折叠直接放到故事场景中去,又加上又是频繁使用的故事情节,想必会增加一些工作量。当功能越来越多的时候,如果不使用 steps within steps 这种模式,我们的手指就得多敲击几次键盘,故事的行数也会明显增加而且增加的都是相同步骤,那样也坏了DRY(Don't Repeat Youself)的规矩。所以,建议在编写一些出现频率较高的故事情节时,适当地折叠一下!

    举个例子,用户在站点的很多操作(比如修改个人资料,发帖等等)都必须是在线状态,用一句话概况就是 “先登录,后操作”。“操作”有若干项,在操作之前,用户登录一次即可。这里的“操作”好比一些将要开发的新功能(比如发帖等)。那么在编写故事用例的时候,场景中免不了用户登录这一情节。结合前面我们编写故事场景的方式,发贴的某个场景应该像这样定义,

        场景: 登录用户发帖
          假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
          当 我以<xuliicom@gmail.com/password>这个身份登录
          那么 我应该看到<登录成功>的提示信息
          而且 我应该成功登录网站
          当 我来到发布新帖页面
          而且 我在...
          ...


    这个场景中仅仅构造用户登录的就有4个子句,即4个步骤或者说4个情节。用一个词来形容,繁琐。当新增的功能越来越多,登录用户要操作这些功能的时候,我们要编写的故事场景个个都会肥胖无比。之所以把需求搬上测试上,就是要让需求更易懂且可行。所以,需要瘦身!“减肥”后的故事如下,

        场景: 登录用户发帖
          假如 我已经登录
          当 我来到发布新帖页面
          而且 我在...
          ...


    这时候虚拟出一个已经登录的用户仅仅只有一行字而已。正所谓浓缩的才是精华,下面笔者就来揭示浓缩精华的秘密。

    $ gedit features/step_definitions/user_steps.rb

    添加如下代码,

    Given /^我已经登录$/ do
    end


    $ gedit spec/factories/user.rb

    添加如下代码,

    Factory.sequence :username do |n|
      "test_user#{n}"
    end

    Factory.sequence :email do |n|
      "test_user#{n}@example.com"
    end

    Factory.define :user do |user|
      user.username              { Factory.next :username}
      user.email                 { Factory.next :email }
      user.password              { 'password' }
      user.password_confirmation { 'password' }
    end


    Factory.sequence 会生成序列,即给传入的参数构造唯一值。在上面的代码中,我们分别给 :username 和 :email 创建了序列,那么当用 Factory.next 访问的时候,每次 :username 和 :email 的值都是不同且唯一的,这更接近真实世界中的(注册)行为。

    $ gedit features/step_definitions/user_steps.rb

    修改 Given /^我已经登录$/ do end 如下,

    Given /^我已经登录(并勾选<记住我>)?$/ do |remember|
      @user = Factory(:user)
      @user.activation_token = nil
      @user.save(false)
      当 %{我以<#{@user.email}/#{@user.password}>这个身份登录#{remember}}
      那么 %{我应该看到<登录成功>的提示信息}
      而且 %{我应该成功登录网站}
    end


    尽管这种写法看起来稍微丑陋,一半是数据操作,另一半是嵌套的情节调用;但却是简化了不少操作,尤其是用户注册和激活功能。不妨来实际应用一下,看看是否生效。

    $ gedit features/user_logout.feature

    修改后的故事用例如下,

    功能: 用户安全退出
      1.提供一个“退出”链接,用户登录后点击该链接可以注销在线状态;
      2.用户登录并勾选记住我后,点击“退出”链接可以注销在线状态,下次访问的时候将不再自动登录。

      场景: 用户注销在线状态
        假如 我已经登录
        当 我退出网站
        那么 我应该看到<您已经安全退出>的提示信息
        而且 我应该尚未登录

      场景: 用户在持久在线状态下退出
        假如 我已经登录并勾选<记住我>
        当 我退出网站
        那么 我应该看到<您已经安全退出>的提示信息
        而且 我应该尚未登录
        当 我关闭网页下次再来访问的时候
        那么 我应该尚未登录


    运行测试,

    $ ruby script/cucumber -l zh-CN features/user_logout.feature




    ### 给测试脚本加上 Helper ###

    user_steps.rb 文件中 “ Given /^我已经登录(并勾选<记住我>)?$/ {...} ” 这段脚本嵌套了其他的 steps 语句,其中有些 steps 还包含其他 steps 子句。很容易看出这些 steps 嵌套的层级较深,这样会聚合大量的正则匹配操作,这些正则匹配是计算时间成本的,多了有损测试速度。所以可以考虑将这些 steps 打回原形,直接用编码实现。如下代码,

    Given /^我已经登录(并勾选<记住我>)?$/ do |remember|
      @user = Factory(:user)
      @user.activation_token = nil
      @user.save(false)
      visit login_path
      fill_in "用户名或邮箱", :with => @user.email
      fill_in "密码", :with => @user.password
      check "记住我" if remember
      click_button "登录"
      response.body.should =~ /登录成功/
      request.session[:user_id].should_not be_nil

    end


    保存。运行测试,

    $ rake features



    测试通过!在常见的WEB应用用,用户在执行某些操作前一般都要求登录。将这些操作对应到测试脚本中,所以登录行为很容易地被看成公共的steps。前面讲过,应该尽量将公共操作放到指定的测试脚本中;因此,上面那段代码还可以再灵活些。只不过,这次会介绍一种新的方式,即使用 Cucumber 的 Helper 模式。

    前面我们在用 Factory_girl 组织测试数据的时候学习到了一个好处,那就是 “一次定义,多处使用” 。这个理念非常经典,同样也是 Rails 提倡的 DRY 的原则之一,Cucumber 的 Helper 也体现了这一经典妙用!

    Cucumber 开放了一个接口,可以集成开发人员以Module方式组织的辅助方法(helper methods),如同在 Rails 中编写 helpers 一样。在 Rails 的 ApplicationHelper 模块中编写的辅助方法可以在任何页面模板中使用;在 Cucumber 中编写的 helper methods 则可以在Cucumber的任何测试脚本(*_steps.rb)中使用。编写辅助方法的详细教程可参阅:http://wiki.github.com/aslakhellesoy/cucumber/a-whole-new-world

    下面,笔者来演示这样一个例子。

    $ gedit features/step_definitions/user_steps.rb

    修改 Given /^我已经登录(并勾选<记住我>)?$/ do end 这段代码如下,

    Given /^我已经登录(并勾选<记住我>)?$/ do |remember|
      test_login!(remember)
    end

    def test_login!(remember = nil)
      user = Factory(:user)
      user.activation_token = nil
      user.save(false)
      visit login_path
      fill_in "用户名或邮箱", :with => user.email
      fill_in "密码", :with => user.password
      check "记住我" if remember
      click_button "登录"
      response.body.should =~ /登录成功/
      request.session[:user_id].should_not be_nil
    end


    保存 user_steps.rb。正常情况下,这时候要死运行测试应该会成功。

    接下来得将 test_login! 这个方法注册到 helper methods 中去,让任意 *_steps.rb 文件中的脚本都可以调用。

    先删除 user_steps.rb 文件中的 test_login! 方法,我们会在 Helper 中重新定义。

    $ gedit features/support/user_helpers.rb

    module UserHelpers

      def test_login!(remember = nil)
        user = Factory(:user)
        user.activation_token = nil
        user.save(false)
        visit login_path
        fill_in "用户名或邮箱", :with => user.email
        fill_in "密码", :with => user.password
        check "记住我" if remember
        click_button "登录"
        response.body.should =~ /登录成功/
        request.session[:user_id].should_not be_nil
      end

    end

    World { |world| world.extend(UserHelpers) }


    这样Cucumber 运行时,World 将会包含 UserHelpers 模块中的方法,即 test_login! 成了可以在任意测试文件中(*_steps.rb)公用的辅助方法。

    保存 features/support/user_helpers.rb。运行测试,

    $ ruby script/cucumber -l zh-CN features/user_logout.feature




    ### 小结 ###

    参与重构工作往往可以让开发人员在意识上踏上一个新的台阶,尤其是对于新手,更是眼前一亮,醍醐灌顶,乃至获得经验上的提升。往后的测试中,想必读者朋友们已经学到如何归类组织steps以及适当地折叠;还知道在什么情况下如何编写 Helper;当然,需要的话,别忘了捎上几名工厂妹(Factory_girl)。


    ### 提交工作成果到GIT仓库 ###

    $ git status
    $ git add .
    $ git commit -m "First refactoring."
    $ git checkout master
    $ git merge refactoring001
    $ git branch -d refactoring001
    $ git tag v7


    (注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)

    标签: CucumberRailsRspecTDD

    作者: fandyst
    出处: http://www.cnblogs.com/todototry/
    关注语言: python、javascript(node.js)、objective-C、java、R、C++
    兴趣点: 互联网、大数据技术、大数据IO瓶颈、col-oriented DB、Key-Value DB、数据挖掘、模式识别、deep learning、开发与成本管理
    产品:
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。
  • 相关阅读:
    Java实现 LeetCode 697 数组的度(类似于数组的map)
    Java实现 LeetCode 697 数组的度(类似于数组的map)
    Java实现 LeetCode 697 数组的度(类似于数组的map)
    Java实现 LeetCode 696 计数二进制子串(暴力)
    Java实现 LeetCode 696 计数二进制子串(暴力)
    Java实现 LeetCode 696 计数二进制子串(暴力)
    Java实现 LeetCode 695 岛屿的最大面积(DFS)
    Java实现 LeetCode 695 岛屿的最大面积(DFS)
    PHP serialize() 函数
    PHP print_r() 函数
  • 原文地址:https://www.cnblogs.com/ToDoToTry/p/2173382.html
Copyright © 2011-2022 走看看