<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敏捷之道。


### 温故知新 ###


    在前面的两篇文章中,笔者向读者朋友们分别演示了用户注册注册用户通过邮件激活帐号的开发过程。当用户注册成功并激活帐号后,系统应该可以让用户登录站点,这就是我们接下来的活儿。

    为了获得更好的阅读体验,读者朋友们可以在这里下载源码:http://github.com/404/bdd_user_demo/tree/master


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

    $ git checkout -b user_login


### 用户登录功能 ###

    1. 提供一张表单,方便用户输入帐号和密码,帐号可以是用户名或邮箱;
    2. 如果用户尚未注册,那么提示登录失败;
    3. 如果用户已注册但未激活,那么系统往该用户的邮箱发送一封激活邮件,并提示用户登录失败,返回登录页面;
    4. 如果用户输入的帐号或密码错误,那么提示用户登录失败,并返回登录页面;
    5. 如果用户输入的帐号和密码正确,那么提示用户登录成功,并将该用户ID记录在session中;
    6. 如果用户输入的帐号和密码正确,且勾选了“记住我”选项,那么提示用户登录成功,并将该用户记录在session和cookie中,然后转向用户登录之前或系统默认的页面。


### 用户登录之“用户尚未注册” ###


    我们简单地罗列了用户登录功能,随后我们将之转化成故事描述以供测试用,每个功能至少对应一个故事情节。我们先从“用户尚未注册”这个功能开始,下面我们编写用于这个功能的故事场景:

    $ gedit features/user_login.feature

    功能: 用户登录
      为了能够浏览网站只对在线会员可见的那些内容
      作为一名访客
      我希望能够登录

      场景: 用户登录功能
        假如 没有<somebody@somedomain.com>这个用户
        当 我以<somebody@somedomain.com/password>这个身份登录
        那么 我应该看到<用户名或密码错误>的提示信息
        而且 我应该尚未登录



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

    $ gedit features/step_definitions/user_steps.rb

    添加如下代码:

    Given /^没有<(.*)>这个用户$/ do |email|
      User.find_by_email(email).should be_nil
    end

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

    Then /^我应该尚未登录$/ do
      request.session[:user_id].should be_nil
    end


    运行测试,

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

   

    “Can't find mapping from "登录页面" to a path. (RuntimeError)”,测试程序未能导向登录页面。还记得前面我们提到过的 paths.rb 文件吗?我们可以在该文件的 path_to 方法中指明登录页面的访问路径。


### 配置路由——login_path ###

    $ gedit features/support/paths.rb

    修改后的 path_to 方法如下,

    def path_to(page_name)
      case page_name
      
      when /首页/
        root_path
      when /用户注册页面/
        signup_path
      when /用户登录页面/
        login_path
      
      # Add more page name => path mappings here
      
      else
        raise "Can't find mapping from \"#{page_name}\" to a path."
      end
    end


    接下来还需要配置路由来定义login_path,不过在此之前我们需要创建一个用于映射login_path的控制器,通常情况下,这样的控制器的会是SessionController。

    $ ruby script/generate rspec_controller Sessions

    在这里,SessionController 的存在是我们定义 login_path 的基础,下面我们来定义登录页面的访问路径。

    $ gedit config/routes.rb

    ActionController::Routing::Routes.draw do |map|
      
      map.with_options :controller => 'users' do |page|
        page.signup '/signup', :action => 'new'
        page.activate '/activate/:token', :action => 'activate'
      end
      
      map.login '/login', :controller => 'sessions', :action => 'new'
      
      map.resources :users, :sessions

    end


    login_path定义完毕,如果你不知道接下来要做什么,此时您可以运行测试看看。


### 创建用于用户登录的表单页面 ###

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

    填充如下代码,

    <% form_tag sessions_path do %>
      <p>
        <%= label_tag 'username_or_email', '用户名或邮箱' %><br />
        <%= text_field_tag 'username_or_email' %>
      </p>
      <p>
        <%= label_tag 'password', '密码' %><br />
        <%= password_field_tag 'password' %>
      </p>
      <p>
        <%= submit_tag '登录' %>
      </p>
    <% end %>



### 登录验证——用户输入的密码是否正确 ###

    $ gedit app/models/user.rb

    在 user.rb 中添加如下两个方法(写在protected之前):
      
    # 验证指定帐号的密码是否匹配
    def self.authenticate(username_or_email, password)
      account = username_or_email.to_s
      user = find(:first, :conditions => ['username = ? or email = ?', account, account])
      user && user.authenticated?(password) ? user : nil
    end

    # 密码是否有效
    def authenticated?(password)
      encrypted_password == encrypt(password)
    end


    数据模型和路由准备就位(这句话大概就是说,我们已经设计好用户该怎么来到注册页面,也告诉后端的数据模型该如何处理数据),下面来编写用于用户登录的业务流程。


### 处理用户登录的业务流程 ###

    $ gedit app/controllers/sessions_controller.rb

    填充如下代码,

    class SessionsController < ApplicationController

      def new
      end

      def create
        @user = User.authenticate(params[:username_or_email], params[:password])
        if @user.nil?
          flash.now[:notice] = '登录失败,用户名或密码错误!'
          render :action => :new, :status => :unauthorized
        else
          if @user.activated?
            sign_user_in(@user)
            flash[:notice] = '登录成功!'
            redirect_back_or user_path(@user.id)
          else
            UserMailer.deliver_confirm(@user)
            deny_access("登录失败!您的帐号尚未激活,系统已经向您的邮箱重新发送了一封激活邮件,请注意查收!")
          end
        end
      end

    end


    如果您又迷惑了,不妨就此打住运行测试看看,测试结果会告诉你该做什么。不过有时候你大脑里或许非常清楚下一步该做什么。

    下一步要做的就是增加访问控制,明确用户登录的意义;如果用户还没有登录,这时候是游客身份,是不可以查看用户资料的,如果登录成功,才可以查看。


### 添加访问控制 ###

    $ gedit app/controllers/application.rb

    在 ApplicationController 中添加如下代码,

    def login_required
      deny_access unless signed_in?
    end

    def signed_in?
      !current_user.nil?
    end

    def current_user
      @_current_user ||= user_from_session
    end

    def user_from_session
      if session[:user_id]
        user = User.find_by_id(session[:user_id])
        user && user.activated? ? user : nil                
      end
    end

    def sign_user_in(user)
      sign_in(user)
    end

    def sign_in(user)
      if user
        session[:user_id] = user.id
      end
    end

    def redirect_back_or(default)
      session[:return_to] ||= params[:return_to]
      if session[:return_to]
        redirect_to(session[:return_to])
      else
        redirect_to(default)
      end
      session[:return_to] = nil
    end

    def store_location
      session[:return_to] = request.request_uri if request.get?
    end

    def deny_access(flash_message = nil, opts = {})
      store_location
      flash.now[:failure] = flash_message if flash_message
      render :template => "/sessions/new", :status => :unauthorized
    end


    这下加了不少代码,得花点时间看看,看明白了就保存application.rb。接着运行测试,

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

   

    测试通过,未注册用户登录失败,这正符合我们的要求;我们再来看看用户已注册但未激活帐号的情况下能否登录成功。


### 用户登录之“用户已注册但尚未激活帐号” ###

    正常情况下,用户已注册但尚未激活帐号的情况下,是不能登录成功的。原因很简单,没激活帐号的用户暂且算不上真正的注册用户。下面来试试看,

    $ gedit features/user_login.feature

    添加如下场景:

        场景: 用户已注册但尚未激活帐号
          假如 我已经使用<404/xuliicom@gmail.com/password>注册过
          当 我以<xuliicom@gmail.com/password>这个身份登录
          那么 我应该看到<帐号尚未激活>的提示信息
          而且 我应该尚未登录


    保存。再次运行测试,

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

   

    测试失败,没有在输出中匹配到“帐号尚未激活”这段文本。在前面的编码中,我们一直使用 flash 传递提示信息,而且我们将呈现 flash 中信息的代码写在了 app/views/layouts/application.html.erb 这个模板中。打开这个文件,找到了这段代码:

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


    还记得我们在ApplicationController类中定义的deny_access方法吗?我们在那里给 flash[:failure] 赋过值,而layout只针对 flash[:notice] 做了处理,看来我们需要修改这个layout模板的代码。

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

    我们将

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


    这段代码替换为

    <div id="flash">
      <% flash.each do |key, value| -%>
        <div id="flash_<%= key %>"><%=h value %></div>
      <% end %>
    </div>


    保存 application.html.erb。再次运行测试,

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

   

    测试通过,两个故事场景都运行成功!在遭遇这样的小小挫折后,增进了我们往后测试的信心!TDD同样是增量开发的,当我们小幅度地尝试,小幅度的迭代,小范围地测试失败之后带来的总是小小成就的欢欣鼓舞。我们何不继续步步为营,稳打稳扎,循序渐进地编写并运行我们的故事尝尝更多的甜头呢?


### 用户登录之“更多情况..” ###

    接下来添加其他的几个故事场景,以测试我们编写的用户登录脚本是否达到指定功能,这下步子可能跨得大了点哦!

    $ gedit features/user_login.feature

    继续在文件尾添加如下文本:

      场景: 用户已激活帐号但密码输入错误
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<xuliicom@gmail.com/wrong_password>这个身份登录
        那么 我应该看到<用户名或密码错误>的提示信息
        而且 我应该尚未登录

      场景: 用户已激活帐号且邮箱和密码输入正确
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<xuliicom@gmail.com/password>这个身份登录
        那么 我应该看到<登录成功>的提示信息
        而且 我应该成功登录网站

      场景: 用户已激活帐号且用户名和密码输入正确
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
        当 我以<404/password>这个身份登录
        那么 我应该看到<登录成功>的提示信息
        而且 我应该成功登录网站


    保存。再次运行测试看看,

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

   

    测试失败,反馈的结果告诉我们还需要分别针对“注册且激活”和“成功登录”定义相关的运行脚本。

    $ gedit features/step_definitions/user_steps.rb

    添加如下代码:

    Then /^我应该成功登录网站$/ do
      request.session[:user_id].should_not be_nil
    end


    然后修改 “ Given /^我已经使用<(.*)\/(.*)\/(.*)>注册过$/ do ... end “ 这段已经定义过的脚本,修改后的代码如下:

    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


    保存user_steps.rb。继续运行测试,

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

   

    OK,测试成功,可以松口气了!至此,离用户登录功能的开发只剩下最后一步了,也是我们接下来要做的工作,那就是实现用户的持久登录状态。当用户输入帐号和密码并勾选“记住我”登录后,用户下次打开浏览器访问的时候依然保持用户的在线状态,那么我们要知道怎么判断用户是否已经登录呢?且听下回讲解!:)


### 小结 ###

    此次开发过程相当顺利,故事场景较之前稍微多一些,还好我们进行的是小幅度的迭代测试,我们的开发工作才得以循序渐进。所以学到的一个经验就是,当业务流程稍微复杂的时候,就要分而治之了。这和大事化小,小事化无的道理是一样的。cucumber的运行是以每一个故事场景为单位,可以让我们很方便地做到这一点。比如我们每次只编一个故事,然后通过运行测试来驱动我们编码后,可以让这个故事能够顺利地跑起来;完了之后又继续下一个故事……稳打稳扎,步步为营,这相当地保险,在TDD上玩迭代开发,那中间想必也多出几次成就感了。另外,我们还学到一点,如果不知道下一步怎么走,在测试准备就绪的情况,只需要轻轻地发动测试引擎一切又柳暗花明了。


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

    $ git status
    $ git add .
    $ git commit -m "A user can be login."
    $ git checkout master
    $ git merge user_login
    $ git branch -d user_login
    $ git tag v3


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

标签: , , ,

2 条评论

  1. Blogger Unknown Says:

    写得很详尽.

    这个step
    而且 %{我在输入框<username_or_email>中输入

    可以改为:

    而且 %{我在输入框<用户名或邮箱>中输入

    webrat会去找label属性for所指的input

    参考webrat rdoc

  1. Blogger 404 Says:

    @saber,谢谢你的提醒!换成 lable 中的文本后确实更具有可读性。用表单域的 id 或者 name 属性的名称也可以,只不过一般要写成英文字母的形式;用中文编故事的话再加几个单词确实不那么连贯,回头我再改过来。:)

» Leave a Reply

订阅 博文评论 [Atom]

« 主页