访问次数
我的另一个总结性博客: todototry 大米粒

使用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

posted @ 2011-09-10 22:08  fandyst  阅读(992)  评论(0编辑  收藏  举报