使用Cucumber+Rspec玩转BDD(6)——找回密码
2009年3月22日 星期日
### 温故知新 ###
用户登出后,过了一段时间再次登录的时候,有时候会忘记密码,这时候系统就得有个找回密码的功能,可以让用户在不用登录的情况下重设密码。对于一个存在的帐号,有且只有一个用户可以修改密码,这个用户必须是此帐号的拥有者;那么,系统怎么知道这个用户就是该帐号的所有者呢?
答案是通过用户注册时填写的电子邮件来重建帐号和用户之间的关联。试想,如果一个用户曾经注册过,他必须填写了有效的电子邮件地址,而且还通过这个邮箱激活过帐号。那么,当注册用户忘记密码后,我们依然可以借助用户注册时填写的邮箱如法炮制,即发送一封带有重设密码链接的邮件,这个链接是唯一的,同样具有有效期,如果用户查收邮件并在有效的时间内访问了那个链接,那么将用户导向重设密码的页面,用户就可以更新密码了。
为了获得更好的阅读体验,读者朋友们可以在这里下载源码: http://github.com/404/bdd_user_demo/tree/master
### 新建工作分支 ###
$ git checkout -b reset_password
### 找回密码功能 ###
1.用户点击“忘记密码?”链接,来到忘记密码页面;
2.用户在忘记密码页面的输入框中输入注册时用的邮箱后,系统发送一封找回密码的邮件到该邮箱中;
3.用户点击邮件中重设密码的链接来到重设密码页面;
4.用户在重设密码页面输入新密码,确认无误提交后,系统将此新密码更新为用户的密码。
### 编写故事用例 ###
$ gedit features/reset_password.feature
经过前面几次的练习,读者朋友们想必已经具备使用Cucumber玩转Testing的实际开发经验了。笔者下面就不一一去写和测试每一个故事了(如果还不是很熟练,可以参考 第3章的介绍的迭代开发方式),而是直接将可能出现的场景全部写了下来。
功能: 重设密码
为了用户在忘记密码后能够找回密码
作为一个注册用户
我应该可以重设密码
场景: 未注册用户请求重设密码
假如 没有<somebody@somedomain.com>这个用户
当 我使用邮箱<somebody@somedomain.com>来找回密码
那么 我应该看到<没有找到somebody@somedomain.com这个用户>的提示信息
场景: 注册用户请求重设密码
假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
当 我使用邮箱<xuliicom@gmail.com>来找回密码
那么 我应该看到<邮件发送成功!>的提示信息
而且 应该有封重设密码的邮件发送至<xuliicom@gmail.com>
场景: 用户重设密码但确认密码输入错误
假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
当 我使用邮箱<xuliicom@gmail.com>来找回密码
而且 我访问<xuliicom@gmail.com>邮件中重设密码的链接
而且 我更新密码为<newpassword/wrong_password>
那么 我应该看到<密码更新失败>的提示信息
而且 我应该尚未登录
场景: 用户成功重设密码
假如 我已经使用<404/xuliicom@gmail.com/password>注册过且已经激活了帐号
当 我使用邮箱<xuliicom@gmail.com>来找回密码
而且 我访问<xuliicom@gmail.com>邮件中重设密码的链接
而且 我更新密码为<newpassword/newpassword>
那么 我应该看到<密码更新成功>的提示信息
而且 我应该成功登录网站
当 我退出网站
而且 我以<xuliicom@gmail.com/newpassword>这个身份登录
那么 我应该成功登录网站
在上面一系列的故事用例中,我们反复定义了 “当 我使用邮箱<xuliicom@gmail.com>来找回密码” 这一情节步骤。这里解释一下缘由:我们编写的每个故事场景都是相互独立的,这意味着上一个场景返回的结果不会在下一个场景中有效;如果要在下一个故事场景中用到上面故事场景中的步骤,就需要在这个故事场景中重复定义上面场景所用的情节(至少在测试代码里边包含针对这一重复情节的相关脚本);这里复用的情节好比是函数中的局部变量,其作用域只在当前故事情节中有效而已。每一个故事场景的运行或多或少的覆盖了用于和用户产生交互的终端页面,包括处理业务逻辑的控制器,甚至和数据模型交互这些,相当于一次集成测试;我们编写的下一个故事场景会在上一个故事的基础上添加一些情节,通过运行这一系列连贯的故事场景,我们的开发工作在覆盖已有功能的基础上跟随这些迭代的集成测试同行。这也正是“测试-驱动-开发”的威力所在。感谢Cucumber的故事运行机制能够如此方便地帮助我们做到这点!
保存 reset_password.feature。运行测试,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
### 编写故事用例运行所需的测试脚本 ###
$ gedit features/step_definitions/user_steps.rb
添加如下代码,
# reset password
When /^我使用邮箱<(.+)>来找回密码$/ do |email|
当 %{我来到找回密码的页面}
而且 %{我在输入框<email>中输入<#{email}>}
而且 %{我按下<发送>按钮}
end
Then /^应该有封重设密码的邮件发送至<(.+)>$/ do |email|
user = User.find_by_email(email)
user.reset_password_token.should_not be_blank
sent = ActionMailer::Base.deliveries.last
sent.to.should eql([user.email])
sent.subject.should =~ /重设/
sent.body.should =~ /#{user.reset_password_token}/
end
When /^我访问<(.*)>邮件中重设密码的链接$/ do |email|
user = User.find_by_email(email)
visit edit_user_password_url(user.id, :token => user.reset_password_token)
end
When /^我更新密码为<(.+)\/(.+)>$/ do |new_password, new_password_confirmation|
而且 %{我在输入框<user_password>中输入<#{new_password}>}
而且 %{我在输入框<user_password_confirmation>中输入<#{new_password_confirmation}>}
而且 %{我按下<更新密码>按钮}
end
保存user_steps.rb。运行测试,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
测试结果告诉我们接下来应该该做些什么;首先,需要让测试程序能够导向找回密码的页面,这个页面及其相关的路由配置尚无;还有未定义reset_password_token等等。任务不少,我们先从比较简单的找回密码页面开始。
### 配置路由 ###
$ gedit features/support/paths.rb
修改 path_to 方法,续添一条找回密码页面的访问路径,
def path_to(page_name)
case page_name
when /首页/
root_path
when /用户注册页面/
signup_path
when /用户登录页面/
login_path
when /找回密码的页面/
new_password_path
# Add more page name => path mappings here
else
raise "Can't find mapping from \"#{page_name}\" to a path."
end
end
保存paths.rb。继续在config/routes.rb文件中定义new_password_path,
$ gedit config/routes.rb
修改map.resources如下,
map.resources :users, :has_one => :password
map.resources :sessions,:passwords
注意,passwords资源是一组嵌套路由,隶属于users资源;如果读者朋友们对 Restful 不陌生,想必一定很好理解。
### 编写找回密码的业务流程 ###
$ ruby script/generate controller Passwords new create edit update
new 方法用来渲染找回密码页面;
create 方法用来发送重设密码的邮件;
edit 方法用来渲染修改密码页面;
update 方法更新用户密码。
修改找回密码页面,
$ gedit app/views/passwords/new.html.erb
<h1>找回密码</h1>
<p>请输入您注册时使用的电子邮箱,系统会发送一封关于重设密码的邮件到您的邮箱中。</p>
<% form_tag passwords_path do %>
<p>
<%= label_tag 'email', '电子邮箱' %>
<%= text_field_tag 'email' %>
</p>
<p>
<%= submit_tag '发送' %>
</p>
<% end %>
保存app/views/passwords/new.html.erb。修改PasswordsController,在create方法中添加一些业务逻辑,
$ gedit app/controllers/passwords_controller.rb
def create
user = User.find_by_email(params[:email])
if user.nil?
flash.now[:notice] = "没有找到#{params[:email]}这个用户"
render :action => :new
else
user.forgot_password!
flash[:notice] = "邮件发送成功!请注意查收。"
redirect_to login_path
end
end
保存app/controllers/passwords_controller.rb。运行测试,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
没有找到User实例对象的forgot_password!方法,因为我们还没有在UserModel中定义该方法;该方法在用户忘记密码提交Email后会生成一份用于重设密码链接的标识码。标识码不仅是构成密码重设链接的一部分,而且还将存入数据库。当用户访问密码重设链接的时候,系统会校验数据库里边的标识码和链接中的标识码是否一致,如果一致则呈现给用户重设密码页面。
下面我们修改UserModel添加这个forgot_password!方法。
$ gedit app/models/user.rb
# 生成忘记密码后的标识码
# 且标识码在24小时后失效
def forgot_password!
self.reset_password_token = generate_token
self.reset_password_token_expires_at = 24.hours.from_now
save(false)
end
我们知道在Ruby的类中,self调用的方法是类方法,self调用的属性是已定义过的实例变量。很明显,上述的forgot_password!方法中self调用的明显是实例变量。要在UserModel类中定义这两个实例变量非常简单,Rails的ActiveRecord会自动将模型类所对应的数据表的字段注册为该模型类的实例变量,基于AR带给我们开发者的一些便利,我们给users表新增这两个字段即可;还记得前面我们说过要将标识码的相关信息存入数据库的这一初衷吗?
下面我们给users新增两个字段,
$ ruby script/generate migration ForgotPassword
$ gedit db/migrate/*_forgot_password.rb
class ForgotPassword < ActiveRecord::Migration
def self.up
add_column :users, :reset_password_token, :string
add_column :users, :reset_password_token_expires_at, :datetime
end
def self.down
remove_column :users, :reset_password_token_expires_at
remove_column :users, :reset_password_token
end
end
$ rake db:migrate
$ rake db:test:prepare
运行测试看看,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
发送关于密码重设的邮件这里出了点问题,事实上这时候邮件根本就还没有发送,因为我们没有编写关于发送密码重设邮件的代码,这可不是一个小的疏忽,得补上。
$ gedit app/models/user_mailer.rb
添加如下代码,
def forgot_password(user, sent_at = Time.now)
subject '请重设您的密码'
recipients user.email
from 'Admin'
sent_on sent_at
body :username => user.username,
:url => edit_user_password_url(user.id, :token => user.reset_password_token)
end
$ gedit app/views/user_mailer/forgot_password.text.html.erb
亲爱的 <%=@username%>:
请点击下面的链接找回您的密码:
<%=link_to @url, @url%>
修改 UserObserver,
$ gedit app/models/user_observer.rb
添加如下代码,
def after_save(user)
UserMailer.deliver_forgot_password(user) if user.forgot_password
end
$ gedit app/models/user.rb
添加,
attr_reader :forgot_password
修改 forgot_password! 方法,
def forgot_password!
@forgot_password = true
self.reset_password_token = generate_token
self.reset_password_token_expires_at = 24.hours.from_now
save(false)
end
保存 app/models/user.rb。运行测试,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
Could not find field: "user_password" (Webrat::NotFoundError)
抛出这一错误不奇怪呃,这个“user_password“表单字段是属于重设密码页面的。到此为止系统已经可以发送一封关于重设密码的邮件到用户的注册邮箱中;可是我们目前还没有编写重设密码的页面及其业务逻辑,我们来打理接下来的工作。
$ gedit app/views/passwords/edit.html.erb
<h1>重设密码</h1>
<%= error_messages_for :user %>
<% form_for(:user,
:url => user_password_path(@user, :token => @user.reset_password_token),
:html => { :method => :put }) do |form| %>
<p>
<%= form.label :password, '新密码' %>
<%= form.password_field :password %>
</p>
<p>
<%= form.label :password_confirmation, '确认新密码' %>
<%= form.password_field :password_confirmation %>
</p>
<p>
<%= form.submit '更新密码', :disable_with => '正在更新,请稍后...' %>
</p>
<% end %>
保存模板文件。修改 PasswordsController,
$ gedit app/controllers/passwords_controller.rb
def edit
@user = User.find_by_id_and_reset_password_token(params[:user_id],
params[:token])
end
def update
@user = User.find_by_id_and_reset_password_token(params[:user_id],
params[:token])
if @user.update_password(params[:user][:password],
params[:user][:password_confirmation])
@user.email_confirm! unless @user.activated?
sign_user_in(@user)
flash[:notice] = "密码更新成功!"
redirect_to user_path(@user.id)
else
flash.now[:notice] = "密码更新失败!"
render :action => :edit
end
end
添加如上两个action后,保存PasswordsController。接着修改 UserModel,
$ gedit app/models/user.rb
在protected之前添加如下代码,
# 更新密码
def update_password(new_password, new_password_confirmation)
self.password = new_password
self.password_confirmation = new_password_confirmation
if valid?
self.reset_password_token = nil
self.reset_password_token_expires_at = nil
end
save
end
保存app/models/user.rb。运行测试,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
当我退出网站,而且我以<xuliicom@gmail.com/newpassword>这个身份登录,那么我应该成功登录网站。这原本是我们之前在最后一个故事场景里写好的预期,但根据上面测试结果可以明显地看出我们用新密码登录失败,这是怎么回事呢?
最容易想到的可能就是密码没有成功被更新;尽管测试结果告诉我们,已经看到<密码更新成功>的提示信息了,根据我们前面学习到的经验,users表记录中加密过的密码不一定真正地变更过……
也许可以经过两次手工测试证明物理数据未被更新这一假设,在此之前,我们还算根据失败的测试结果找找程序上的原因。既然我们假设users表中的密文没有更新,不妨从UserModel开始;当编辑器的滚动条在文件app/models/user.rb中上下滑行的一小会儿时间里,我们惊奇地发现问题大概是处在钩子方法那里。下面是一小段源码,
# 钩子方法,保存之前生成 password_salt
# 并使用 password_salt 和原始密码来加密生成新密码
before_create :initialize_salt, :encrypt_password, :initialize_activation_token
现在你应该看出是什么问题了?问题出在这个时候就不应该用before_create,而应该是before_save;前者会在数据模型创建新记录之前调用参数中指定的方法;后者则在新建记录或者更新旧有记录时都会执行回调。在这个应用程序中,新用户注册需要生成密文,这会创建新记录;已经注册过的用户更新密码同样需要经过加密处理,这一操作会更新旧有记录;无论是新建还是更新记录,它们彼此都有一个共同操作,就是将数据保存到数据库这一过程,在这个关口上执行回调处理,也只有before_save能担此大任。
我们将上面代码中的before_create修改为 before_save,然后修改initialize_activation_token方法如下,
# 生成激活码
def initialize_activation_token
if new_record?
self.activation_token = generate_token
end
end
new_record? 方法用来判断是不是新记录。或者改成下面这样也行,
before_save :initialize_salt, :encrypt_password, :encrypt_password
before_create :initialize_activation_token
保存app/models/user.rb;运行测试,
$ ruby script/cucumber -l zh-CN features/reset_password.feature
测试通过!hoho~ 最后笔者再重复强调一遍:开发人员最好亲临现场手工测试几次,确保程序真的万无一失。
### 小结 ###
在这一章里,我们学习到了这些知识:Cucumber的运行是以每个故事用例为单位进行的一次集成测试;每个故事用例之间彼此独立(在测试运行时独立),又彼此有联系(基于已有功能叠加);故事场景中的情节只在当前故事中有效,无法跨场景访问,如果需要在其他场景中运行,必须在其他场景中重复定义该情节。
### 下节预告 ###
通过这一系列教程:用户注册、邮件激活帐号、用户登录、登录并“记住我”、用户注销退出和找回密码,我们使用Cucumber+Rspec一步一步循序渐进地开发完成了一个基本的用户帐号系统。这个系统,说大不大,说小也不小了,基本上每个开放注册的站点都会有这么一个用户系统;所以,是时候将它作为一个独立的模块release出来了,不过在此之前,重构和测试是迭代开发中必不可少的一步。在接下来的一章里,笔者将会围绕测试重构展开,我们会配置Cucumber以适应测试环境,还有给测试代码添加辅助方法以及使用Factory_girl等细节,敬请关注!
### 提交工作成果到GIT仓库 ###
$ git status
$ git add .
$ git commit -m "People can be change their passwords."
$ git checkout master
$ git merge reset_password
$ git branch -d reset_password
$ git tag v6
(注意,真正的开发中可不是到功能开发完毕了才commit,而是边开发边add和commit。为了方便演示编码过程,文章中没有一一列举。)