<body><script type="text/javascript"> function setAttributeOnload(object, attribute, val) { if(window.addEventListener) { window.addEventListener('load', function(){ object[attribute] = val; }, false); } else { window.attachEvent('onload', function(){ object[attribute] = val; }); } } </script> <div id="navbar-iframe-container"></div> <script type="text/javascript" src="https://apis.google.com/js/platform.js"></script> <script type="text/javascript"> gapi.load("gapi.iframes:gapi.iframes.style.bubble", function() { if (gapi.iframes && gapi.iframes.getContext) { gapi.iframes.getContext().openChild({ url: 'https://www.blogger.com/navbar.g?targetBlogID\x3d3443179681250049771\x26blogName\x3dNewbie+On+Rails\x26publishMode\x3dPUBLISH_MODE_BLOGSPOT\x26navbarType\x3dBLACK\x26layoutType\x3dCLASSIC\x26searchRoot\x3dhttps://l404.blogspot.com/search\x26blogLocale\x3dzh_CN\x26v\x3d2\x26homepageUrl\x3dhttp://l404.blogspot.com/\x26vt\x3d-4382633446460227058', where: document.getElementById("navbar-iframe-container"), id: "navbar-iframe" }); } }); </script>

Newbie On Rails

方便初学Rails的朋友们快速上手并驶入BDD敏捷之道。


### 引言 ###


    测试驱动开发的美名即TDD(Test-Driven Development 的缩写);顾名思义,就是利用测试来驱动程序的设计及其实现。在这一过程中,先写测试程序,然后再编码使其通过测试,经过几次反复的迭代后,使得程序的实用性达到开发人员的理想需求。
    
    Rspec是一种实现BDD的工具,倡导在编写测试的同时描述代码的行为,BDD即行为驱动开发(Behaviour-Driven Development),从TDD进化而来,算是TDD的一个分支,使用Rspec编写的测试代码更加灵活也更具有趣味性。
    
    Cucumber继承了BDD的先进理念并由此衍生出了SDD(Story-Driven Development)这一特性,可以说是继承并发扬了BDD的DSL特点,从而使得测试中的行为更加具有连贯性和观赏性,当一系列的行为串起来,一个精彩生动的故事就应运而生了。Cucumber以故事用例为单位,每个故事对应一小段测试代码(可重用),每一小块功能需求就写成一段故事描述文本。当你了解这样的规则并应用规则熟练后,把用户的直接需求搬到测试上去就轻而易举了。
    
    如果说TDD是发芽期,那么BDD就是鲜花盛开时,到SDD就是该结果了,不是吗?UnitTest的出现就像测试兴起之初露出的小芽尖儿;Rspec / Shoulda的流行意味着测试春天的悄然而至;后来盼来了个硕果——Cucumber,英译汉即黄瓜;还等什么,赶紧搞根黄瓜尝尝!


### 创建一个新的项目 ###

    $ cd ~/code
    $ rails user_demo && cd user_demo


    可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


### 功能需求 ###

    在进行后面的开发之前,先来预告本节开发工作的主要内容;以下是简要的用户注册功能:

    1. 提供一张表单,方便用户填写注册资料;
    2. 用户填写填写注册资料并提交后,若信息填写有误,则提示用户注册失败,并返回注册页面;
    3. 用户填写填写注册资料并提交后,若信息填写正确,提示用户注册成功,并跳转到用户资料页面。

    非常简单,是不是?下面我们就按cucumber要求的形式将以上的功能需求转化成一个个故事用例。


### 编写用户注册的故事用例 ###

    $ ruby script/generate cucumber
    
    事先需要安装 Cucumber 及其依赖包,如尚未安装,可以运行 gem install cucumber 来安装。
    
    接下来在 features/ 目录下面创建一个 user_signup.feature 文件,用来写我们期望的故事(功能需求);你可以选择任意一款你喜欢的编辑器来填充这个文件(为方便演示,文章中新建或修改文件均用gedit)。
    
    $ gedit features/user_signup.feature
    
    功能: 注册成为网站会员
      为了能够浏览网站只对在线会员可见的那些内容
      作为一名访客
      我希望注册成为网站会员
      
        场景: 用户填写无效数据并注册
          当 我来到用户注册页面
          而且 我在输入框<用户名>中输入<invalid username>
          而且 我在输入框<电子邮箱>中输入<invalid email>
          而且 我在输入框<密码>中输入<password>
          而且 我在输入框<确认密码>中输入<verify password>
          而且 我按下<注册>按钮
          那么 我应该看到<注册失败>的提示信息
          
        场景: 用户填写正确的数据并注册
          当 我来到用户注册页面
          而且 我在输入框<用户名>中输入<404>
          而且 我在输入框<电子邮箱>中输入<xuliicom@gmail.com>
          而且 我在输入框<密码>中输入<password>
          而且 我在输入框<确认密码>中输入<password>
          而且 我按下<注册>按钮
          那么 我应该看到<注册成功>的提示信息

          
    上面就是我们写好的故事剧本,关于用户注册的;不仅开发人员易懂,而且也接近PM给我们的书面需求了(有时候你可能会写得更详细)。当我们按照这种约定俗成的格式线性地描述一番后,Cucumber的故事驱动器会虚拟出一个故事的主人公(比如叫Robot),Robot会亲历每个场景,并遵循当前故事场景中的每个情节扮演每一个动作,每个动作的成败都会被Cucumber记录下来,等到演出结束后汇报给我们详细的数据。那么你可能会好奇Robot该如果演出我们写好的情节呢?我们写好了故事剧本,Robot到底有没有按照我们的意思去演,我们是否可以要求他按照我们的想法去做?其实里面的Robot比较老实本份,你不给他说清楚细节以及给出相关的道具,他会呆在那里傻呼呼的望着你,一旦万事俱备,你只要点点头,他将以光速前进(010110101...)。为了让Robot完成我们的意愿,还需要做些事情,就是告诉他如何进场以及每个细节动作的规范。


### 编写用于故事运行的测试代码 ###

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


    在第1小段代码中,我们将测试中的当前行为引导至正则匹配到的页面那里;这里正则匹配到的页面名称应该是“用户注册页面”,但测试程序并不知道该页面的访问路径,所以我们需要手工声明一下。还好,Cucumber生成的文件中为我们准备好了path_to()方法,我们只要根据需要修改原生的features/support/paths.rb这个文件就可以了,修改后的代码如下:
    
    $ gedit features/support/paths.rb
    
    def path_to(page_name)
      case page_name
      when /首页/
        root_path
      when /用户注册页面/
        signup_path
        
      # Add more page name => path mappings here
      
      else
        raise "Can't find mapping from \"#{page_name}\" to a path."
      end
    end


    参照以上的流程控制,你可以根据需要添加更多的页面路径;当我们的故事越来越多的时候,比如用户登录页面呀找回密码页面呀什么的,我们会频繁地在path_to()方法中增加更多的分支。

    上面几个简单的步骤已经把用户注册的这个需求功能写明白了,同时还设计好了描述故事主人公行为的代码,我们不妨运行下测试,测试结果会告诉我们接下来该做什么。


### 观测试了解工作内容 ###

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


   
    
    可以看到测试没有通过,两个演出场景都以失败告终。红色的表示所在的故事情节没有通过测试,并抛出了错误信息(准确地说应该是非常实用的调试信息)。只有当尖括号括起来的文本颜色为绿色时,才是测试通过了的故事情节。根据抛出的错误信息可以知晓,不仅用户注册也没未找到,而且 signup_path 这个路径也未定义,其页面表单项自然也就不存在了。
    
    到目前我们只写好了用户注册的故事以及模拟该故事演示的脚本,对于我们的应用程序,我们还没有真正开始编码,也只能看到这一堆花花绿绿的出错信息了。如果你习惯了的话(你会喜欢的),我想你不会把这些信息当作出错信息来看,至少它们能告诉我们少作了什么以及该做哪些事情,这是非常有价值的情报,它能把最终用户的需求用代码的形式直观地呈现在开发人员的眼前。
    
    看来我们真正的目标该现身了!我们需要根据测试未通过的调试信息来指引我们开发正确并真实的用户注册程序。测试程序没有找到注册页面,此时我们可以用Rails内置的生成器一同创建。


### 使用生成器快速实现程序结构 ###

    $ ruby script/generate rspec

    $ ruby script/generate rspec_model User username:string email:string encrypted_password:string password_salt:string

    $ rake db:migrate

    $ rake db:test:prepare

    $ ruby script/generate rspec_controller Users new create show

        
    短短几行命令,就创建了User模型和users控制器以及控制器中的3个方法;请求new()方法会返回用户注册页面(也是之前测试程序要访问的),create()方法用来收集并递交用户填写的注册数据,show()方法是新用户注册成功后用来显示用户资料的页面。除此之外,Rspec生成器还帮我们创建了几个配套的测试文件,后面我们会用到。

    光有程序结构还不行,还得告诉终端用户在注册页面的访问路径。


### 布置路由(Routes) ###

    $ gedit config/routes.rb

    ActionController::Routing::Routes.draw do |map|
      map.signup '/signup', :controller => 'users', :action => 'new'
      map.resources :users
    end

    
    如上述修改后的routes.rb,我们配置了注册页面的具名路由——signup,还有给users这组资源加上了标准的REST。
    
    牵好线,搭好桥后;再来告诉程序应如何处理用户注册。
    

### 处理用户注册的业务流程 ###

    $ gedit app/controllers/users_controller.rb

    class UsersController < ApplicationController
      
      def new
        @user = User.new
      end
      
      def create
        @user = User.new(params[:user])
        if @user.save
          flash[:notice] = '注册成功!'
          redirect_to(@user)
        else
          flash[:notice] = '注册失败!'
          render :action => "new"
        end
      end
      
      def show
        @user = User.find(params[:id])
      end
      
    end



### 布局模板 ###

    在修改用户注册页面之前,来段小插曲,我们新建一个公共模板页面,里面放些用于程序呈现提示消息的一些内容。

    $ gedit app/views/layouts/application.html.erb

    <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
    <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
    <head>
      <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
      <title><%= controller.controller_name %>#<%= controller.action_name %></title>
    </head>

    <body>

    <% if flash[:notice] -%>
      <p style="color: green"><%= flash[:notice] %></p>
    <% end -%>

    <%= yield %>

    </body>
    </html>


    我们在公共模板页添加了用于显示提示信息的代码,这样无论用户注册成功与否,我们都能看到相关的提示信息。接下来修改用户注册页面填充一张供用户填写注册资料的表单项,

    $ gedit app/views/users/new.html.erb

    <h1>注册新用户</h1>

    <% form_for(@user) do |f| %>
      <%= f.error_messages %>
      <p>
        <%= f.label :username, '用户名' %><br />
        <%= f.text_field :username %>
      </p>
      <p>
        <%= f.label :email, '电子邮箱' %><br />
        <%= f.text_field :email %>
      </p>
      <p>
        <%= f.label :password, '密码' %><br />
        <%= f.password_field :password %>
      </p>
      <p>
        <%= f.label :password_confirmation, '确认密码' %><br />
        <%= f.password_field :password_confirmation %>
      </p>
      <p>
        <%= f.submit "注册" %>
      </p>
    <% end %>

    
    页面准备完毕,我们将其用浏览器打开看看是否是我们想要的样子。启动 Web Server,

    $ ruby script/server

    在浏览器的地址栏中输入 http://localhost:3000/signup 打开注册页面。输出,
    
   
    
    我们看到出错了;提示在User模型的实例中没有找到password这个方法。回顾之前的操作,我们用rspec_model生成器创建User数据模型的时候确实没有指定password这个字段;因为数据库不保存明文密码,所以我们指定了encrypted_password这个数据字段,当把明文密码加密后才存到数据表的此列中。我们需要修改User模型,给它加上明文密码属性password和确认密码password_confirmation。

    $ gedit app/models/user.rb

    class User < ActiveRecord::Base
      attr_accessor :password, :password_confirmation
    end

    
    刷新注册页面,这次能够正常显示了。如图:

   
    
    当新用户注册成功后,会跳转到显示用户信息的页面,根据需要呈现的信息,我们对这个页面作一些修改。
    
    $ gedit app/views/users/show.html.erb
    
    <h1>用户资料</h1>
    <p>
      <b>用户名:</b>
      <%=h @user.username %>
    </p>
    <p>
      <b>电子邮箱:</b>
      <%=h @user.email %>
    </p>

    
    再次运行测试看看~
    
    $ ruby script/cucumber -l zh-CN features/user_signup.feature
    
    可以看到,有一个步骤没有通过测试,如下图:

   
    
    根据以上图中的调试信息,我们看到在第一个场景中,测试程序使用无效的数据注册后并没有返回“注册失败”的提示信息,这意味着不管用户有无填写注册数据,抑或注册数据无效都可以成功注册。天啊,那是多么严重的Bug,此时此刻,我们应该赶紧补上这个漏洞。


### 修改 Model,增加一些必要的数据校验 ###

    $ gedit app/models/user.rb

    class User < ActiveRecord::Base

      # 因为数据库保存的密码是加密后的,
      # 为了设计完整的User模型,
      # 在这里需手工声明 password 和 password_confirmation 属性;
      # 当然,app/views/users 中的部分模板也会用到这两个属性;
      # 另一个好处是为了方便测试。
      attr_accessor :password, :password_confirmation

      # 密码长度不小于4或大于40个字节
      validates_length_of :password, :within => 4..40

      # 用户两次密码输入必须一致
      validates_confirmation_of :password

      # 必填项
      validates_presence_of :username, :email, :password

      # 用户名和密码唯一
      validates_uniqueness_of :username, :email

      # 必须是有效的email格式
      validates_format_of :email,
        :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
        
    end

    
    当在 User 模型中增加这些数据校验代码后,我们的漏洞应该算是补上了,不妨测试看看:
    
    $ ruby script/cucumber -l zh-CN features/user_signup.feature

   
    
    可以看到,2个场景中的14个情节步骤全部通过,哈哈!是不是有点成就感呢?几个回合之后,在用户注册这个故事用例的测试驱动下,我们一步一步编写了真实有效的用户注册程序;对!就是客户需要的用户注册功能,简直就是贴近客户口头需求的应用开发呀!
    
    到此,第一根黄瓜啃完了,如果你此时非常满足可以邀请你的客户过来试用了,然后抿口绿茶后帮助客户打开注册页面,同时在一旁观察享受着你的工作成果。客户按照之前口头跟你说的那个用户注册故事,第一次故意填入无效的数据并提交注册,哎呀,返回一些错误信息,接着使用不同的错误数据多试了几次。好几次后,客户看了看错误信息并按要求填入正确的数据并提交注册,跳转到显示用户资料的页面并提示注册成功,客户对你的工作成果非常满意,赞叹你的工作效率是如此的高效!


### 亲临现场 ###
    
    不过,作为一个开发人员,404强烈建议在交付给客户查看之前开发人员最好亲临现场手工测试一边,以防万一,正所谓慎在于畏小;若有闪失,客户大概会提醒你们改进,但若因为一丁点儿小错误导致难以处理的麻烦就得不尝失了,比如黑客剽窃了所有没有加密的信用卡密码,这下可就丢大了。
    
    为了不必要的麻烦,我们自己来手工测试一遍!毕竟是开发人员,测试起来更了解细节想到的也会周全些。我们打开注册页面,试了几次填入无效的数据并注册,结果如我们想象中一样失败了;然后我们填入正确的数据并注册,我们看到注册成功的友好信息。但我们看到的仅仅是注册成功的表象,要透过现象看本质,就得了解注册成功的确切证据是什么?最有说服力的是数据,我们不妨查看写入到数据库中的数据是否是我们想要的结果。
                    
    假设我们之前以用户“mouse”成功注册,我们来查询下数据库看看他填写的数据是否符合我们设定的标准。

    检查入库数据是否有效,

    $ ruby script/console
    >> User.find_by_username('mouse')


   

    根据输出结果,我们看到 “encrypted_password: nil, password_salt: nil”这行,这确凿无疑地说明密码根本没有保存到数据库中。此时你是否想起了还没有加密明文密码的这一过程?这样的情况若是出现在之前假设的信用卡例子中,后果将不堪设想。
    
    不测试的代码不是合格的代码,不写测试的程序员不是好的程序员哦;只有开发人员亲历过的测试才算得上可靠的测试。切记!

    亡羊补牢,为时未晚;好在我们还处于开发进行时,这点漏洞我们可以立马补上。经过前面的教训,我们知道Cucumber只不过是按流程走过场而已,这个流程也就是模拟MVC总终端到处理数据的模式,Cucumber 的出现一定程度上集成了Rspec-Rails测试框架中对View和Controller以及Model的一条龙测试,不过在校验数据的粒度测试上,Cucumber却显得相形见绌,还没单元测试好用;试想每当验证某个数据是否合格时,是写个场景好呢还算写一两行代码方便?,显然是后者咯!这种对比不过是寸有所长,尺有所短罢了,只要我们集每种工具所长用得恰到好处就行,就如同协调一个优秀的团队。
    
    考虑到Cucumber和BDD结合慎密,这里将选用Rspec-Rails作为后发测试工具,Rspec/Rspec-Rails这样优秀的测试框架在对数据模型的测试已经非常成熟,这这篇文章中,或许还用不到Rspec的一些高级特性,不过用最简单的技术做出有价值的事情就已经非常不错了!


### 通过Rspec-Rails测试UserModel完善数据校验 ###

    Rspec-Rails是一款我们熟悉的针对Rails的BDD测试框架,为了补上前面提到的那个未加密的漏洞,我们习惯性的采用了测试先行的策略。

    $ gedit spec/models/user_spec.rb

    修改过的测试代码如下:

    require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')

    describe User do
      before(:each) do
        @valid_attributes = {
          :username                => '404',
          :email                   => 'xuliicom@gmail.com',
          :password                => 'password',
          :password_confirmation   => 'password'
        }
        @user = User.new(@valid_attributes)
      end
      
      it "should encrypt the password before saving to the database" do
        @user.save
        @user.password_salt.should_not be_nil
        @user.encrypted_password.should_not be_nil
      end
      
    end

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


   
    
    我们看到测试失败了,这不得不督促我们应该做点事情使得测试能够顺利通过,我们可以从编写密码加密的相关代码着手。
    
    $ gedit app/models/user.rb
    
    修改后的user.rb文件内容如下:
    
    require 'digest/sha1'

    class User < ActiveRecord::Base

      # 因为数据库保存的密码是加密后的,
      # 为了设计完整的User模型,
      # 在这里需手工声明 password 和 password_confirmation 属性;
      # 当然,app/views/users 中的部分模板也会用到这两个属性;
      # 另一个好处是为了方便测试。

      attr_accessor :password, :password_confirmation
      
      # 密码长度不小于4或大于40个字节
      validates_length_of :password, :within => 4..40
      
      # 用户两次密码输入必须一致
      validates_confirmation_of :password
      
      # 必填项
      validates_presence_of :username, :email, :password
      
      # 用户名和密码唯一
      validates_uniqueness_of :username, :email
      
      # 必须是有效的email格式
      validates_format_of :email,
        :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
        
      # 钩子方法,保存之前生成 password_salt
      # 并使用 password_salt 和原始密码来加密生成新密码

      before_create :initialize_salt, :encrypt_password
      
      # 生成加密后的字符串并返回
      def encrypt(string)
        generate_hash("--#{password_salt}--#{string}--")
      end
      
      protected
      
      # 加密指定的字符串并返回
      def generate_hash(string)
        Digest::SHA1.hexdigest(string)
      end
      
      # 生成 password_salt并返回
      def initialize_salt
        if new_record?
          self.password_salt = generate_hash("--#{Time.now.to_s}--#{email}--")
        end
      end
      
      # 生成加密后的新密码并返回
      def encrypt_password
        return if password.blank?
        self.encrypted_password = encrypt(password)
      end
      
    end


    运行测试,看看加密明文密码是否起作用了。
    
    $ ruby script/spec spec/models/user_spec.rb

   
    
    测试通过,显然在用户资料被保存之前密码被加密已是事实了!前面讲到Cucumber在对数据校验的粒度测试上不是特别灵活,于是在这里我们可以把对User模型对象的数据校验测试也一同放入user_spec.rb中。

    $ gedit spec/models/user_spec.rb

    require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')

    describe User do
      before(:each) do
        @valid_attributes = {
          :username               => '404',
          :email                  => 'xuliicom@gmail.com',
          :password               => 'password',
          :password_confirmation  => 'password'
        }
        @user = User.new(@valid_attributes)
      end
      
      it 'should be valid' do
        @user.should be_valid
      end
      
      it 'should require a username' do
        @user.username = ''
        @user.should_not be_valid
        @user.should have(1).errors_on(:username)
      end
      
      it 'should not be valid without a email' do
        @user.email = ''
        @user.should_not be_valid
      end
      
      it 'should be valid if email past the correct match' do
        @user.email.should match(/^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i)
      end
      
      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 not be valid without a password' do
        @user.password = ''
        @user.should_not be_valid
      end
      
      it 'should be valid if password has a minimum of 4 characters' do
        @user.password.should have_at_least(4).characters
      end
      
      it 'should be valid if password has not more than 40 characters' do
        @user.password.should have_at_most(40).characters
      end
      
      it 'should be valid if password and password confirmation match' do
        @user.password_confirmation.should == @user.password
      end
      
      it "should encrypt the password before saving to the database" do
        @user.save
        @user.password_salt.should_not be_nil
        @user.encrypted_password.should_not be_nil
      end
      
    end

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

   
    
    如果你本机配置好了autotest/notifier,可以将Cucumber和Rspec-Rails测试代码一并运行。这里为了方便演示,暂时删除下面几个文件:
    
    $ rm -rf spec/controllers/
    $ rm -rf spec/helpers/
    $ rm -rf spec/views/users
    
    $ AUTOFEATURE=true autospec


   
    
    最后,为了确认没有疏忽还是手工测试一遍好,自己写的东西当然要在第一时间enjoy! 我们不妨再注册一个用户看看。

   
    
    不好意思呃,没有注册成功(因为“mouse”用户已经存在了,我们前面在User模型里边设置了用户名和email必须唯一),我们换用“mouse2”试试看,正如你所料,注册过程相当顺利。我们看看“mouse2”这位用户的注册资料是否符合我们的要求。
    
    $ ruby script/console
    >> User.find_by_username('mouse2')


   

    我们看到密码被加密过了,还生成了一份密码摘要,其他数据也符合我们定义的要求,总算可以松口气,是时候该主动报告我们的工作成果了!


### 下节预告 ###

    在本系列的下一篇文章里,404会继续整理以前的一些学习笔记供学友们参考;还是测试的一些内容,您会了解如何用Cucumber测试发送邮件以及邮件激活帐号;额外地,为了更好的管理开发工作,您得用上GIT。希望大家在阅读本系列的同时能及时给出一些反馈或者建议;如果您有更好的想法或者经验心得,非常期望与您交流,您可以在下面留言或者email到xuliicom@gmail.com。

标签: , , ,

1 条评论

  1. Blogger 老熊 Says:

    好东西。。。。学习了

» Leave a Reply

订阅 博文评论 [Atom]

« 主页