使用Cucumber+Rspec玩转BDD(7)——测试重构
2009年4月5日 星期日
### 温故知新 ###
在前面的六个章节中,我们循序渐进地完善了一个用户帐号系统,这样的系统一般都会作为一个独立的模块交付。在交付这个模块之前,还需要进一步地做些重构工作。在这篇文章中,笔者将会围绕测试重构展开。
源码下载: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。为了方便演示编码过程,文章中没有一一列举。)