• 使用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、开发与成本管理
    产品:
    本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接。
  • 相关阅读:
    Python-Collections模块之defaultdict
    Python-Collections模块之deque
    Python-Collections模块之NameTuple
    Python-Collections模块之OrderedDict
    @常见的远程服务器连接工具:Xshell与secureCRT的比较!!!(对于刚接触的测试小白很有帮助哦)
    告诉你:传统测试和敏捷测试的区别!!!(比较全的解答,一看便明白)
    @软件测试环境搭建的详细流程(初学者不要错过哦,很实用)!!!!
    Netlib文件转化为mps文件
    新设备关联Gitlab
    CMake与OpenMP
  • 原文地址:https://www.cnblogs.com/ToDoToTry/p/2173382.html
Copyright © 2020-2023  润新知