使用Cucumber+Rspec玩转BDD(2)——邮件激活

来源:转载

使用Cucumber+Rspec玩转BDD(2)——邮件激活

2009年3月2日 星期一

### 温故知新 ###

    前面我们已经完成了新用户注册功能的开发,为了方便我们后面的开发工作且不扰乱之前的工作成果,我们先将这份源代码归档并做个标记。

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

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

    $ cd ~/code/user_demo
    $ git init
    $ git add .
    $ git commit -m 
"A user can be able to sign up."
    $ git tag v1


    “git init” 会在 ~/code/user_demo 目录中初始化版本库;接着 “git add .” 将 user_demo 目录中的所有文件信息编入索引(index files);然后 “git commit” 命令将根据 index 中的信息将工作内容提交到项目的GIT仓库里边去,-m 选项加上了本次提交的一些说明;最后 “git tag” 给这次提交所生成的版本号标记了一个别名叫 v1。

    其实好习惯是在新建rails-app后就初始化版本库。由于篇幅的关系,笔者才将些许GIT的内容放到这篇文章中。这不,正好派上用场喽!

    在主干(master)上工作是危险的,因为控制的不够好会扰乱版本,这不是我们愿意看到的。为此,GIT允许我们在主干道的基础上建立新的分支(branch),在分支中进行开发工作,这样好控制风险。比如有时候分支中的工作搞得一塌糊涂,开发人员想重来的时候,直接丢掉删除这个分支再新建一个工作分支重新工作就是了,这对项目中的主干完全没有丝毫影响(不会扰乱你上次提交到master中的工作成果),等你在新分支中开发完毕后,再将这个分支中的工作成果归并到主干中就行。GIT的分支告诉我们,丢掉一个烂摊子比收拾一个烂摊子要轻松得多;潜意识里,我们几乎一致认为这对开发人员的大脑是友好的!:)

    下面我们在主干的基础上为后面邮件激活这个功能的开发新建一个分支。


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

    $ git checkout -b email_activation

    或者,

    $ git branch email_activation
    $ git checkout email_activation

    查看当前工作所在的分支,

    $ git branch

    会返回项目中的所有分支,前面加星*就是当前的工作分支。

    做开发要步步为营,不是吗?git可以很方便地帮我们做到这点。在归档源码后,接着我们新建了一个名为 email_activation 的分支,并将当前的工作状态从master主干切换到email_activation分支中。这里说明下,此时的 email_activation 相当于之前源码(v1)的一份副本,这份副本是我们进行后续开发的基础;后面我们将在用户已经能够注册的基础上进行用户激活帐号的开发工作,只不过在这个基础上所开发的一举一动都会被记录到email_activation分支中。当用户注册成功并能通过邮件激活帐号后,我们就可以将email_activation分支下的工作成果提交且归并到master主干中,从而把邮件激活的功能和用户注册的功能完美的衔接在一起,同时使得项目的版本干净整洁。

    如果我们在email_activation的分支中的开发工作不尽人意,怎么办呢?如果是一些小小的修改,那非常好办,直接改成你想要的就是了;可如果是大范围地修改后,结果却不是你想要的,有时会萌发重做的想法。下面就来告诉你一些开倒车的技巧:

    如果新增了文件,需要先用git add添加(这会被编入git的index,但不会提交到git仓库),否则回滚后会遗留下来(这句话好像就是说,等到你重新开发的时候发现要编码的文件已经存在了)。可以用 git status 命令查看都添加或者修改了哪些文件。

    如果你当前的工作目录(working tree)已经混乱不堪,但是还没有提交,可以使用:

    $ git reset --hard

    这会丢弃所有的改变,包括去除已经加到git index里边的内容;然后将 working tree 和 index 恢复到上次commit时的状态。

    如果想回滚到一个指定的版本,就需要指定版本号:

    $ git reset --hard v1

    v1 是我们在之前标记过的别名,即上次commit所生产的版本号别名,也可以替换成commit后的版本号,比如 af2d45c... ,版本号是一个唯一的哈希值,每次commit都会生成一个,省去了你找不到版本号的尴尬;基本上,使用git log 命令都能看到版本号。指定版本号的时候不需要写上所有字符,取前5个就可以,反正能说明版本号是唯一的就行了。比如你只有两次提交记录,指定版本号的时候取哈希值的前两个字符又何尝不可呢?

    还有,记得 --hard 选项要慎重使用,具体的您可以使用 “git reset -h” 命令查阅更多关于撤销修改的详细信息。

    如果只是想放弃对某一文件的修改,可以使用 checkout 命令。这个命令不单用于分支间的切换,还可以回滚一个指定的文件内容到上次所做的修改,例如:

    $ git checkout app/models/user.rb

    这会放弃对user.rb所做的修改,并将user.rb的内容从上一个已提交的版本中更新回来。当然还可以指定回滚到指定版本,例如:

    $ git checkout v1 app/models/user.rb

    这会将user.rb的内容从已提交的v1所对应的版本中更新回来。

    好了,到此您已经了解了一些实用的GIT知识;是时候步入正题进行我们的开发工作了,我们来了解下工作内容。


### 邮件激活功能 ###

    1. 用户成功注册成为网站用户;
    2. 系统发送一封包含激活链接的邮件到用户注册时填写的邮箱中;
    3. 用户点击邮箱中的激活链来接激活帐号;
    4. 用户帐号激活成功,并给出帐号激活成功的提示消息。

    根据上面的功能需求,我们在前面两个故事的基础上再添两笔。


### 故事用例之用户通过邮件激活帐号 ###

    $ gedit features/user_signup.feature

    修改后的文件内容如下,

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

      场景: 用户填写无效数据并注册
        当 我来到用户注册页面
        而且 我在输入框<用户名>中输入<invalid username>
        而且 我在输入框<电子邮箱>中输入<invalid email>
        而且 我在输入框<密码>中输入<password>
        而且 我在输入框<确认密码>中输入<verify password>
        而且 我按下<注册>按钮
        那么 我应该看到<注册失败>的提示信息
        
      场景: 用户填写正确的数据并注册
        当 我来到用户注册页面
        而且 我在输入框<用户名>中输入<404>
        而且 我在输入框<电子邮箱>中输入<xuliicom@gmail.com>
        而且 我在输入框<密码>中输入<password>
        而且 我在输入框<确认密码>中输入<password>
        而且 我按下<注册>按钮
        那么 我应该看到<注册成功>的提示信息
        而且 应该有封激活帐号的邮件发送至<xuliicom@gmail.com>

      场景: 用户激活帐号
        假如 我已经使用<404/xuliicom@gmail.com/password>注册过
        当 我访问<xuliicom@gmail.com>邮件中激活帐号的链接
        那么 我应该看到<帐号激活成功>的提示信息

    我们只是在已有的故事上加了个别子句。为了能让故事跑起来,我们还需要针对故事场景中的情节编写相应的测试代码。


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


    $ gedit features/step_definitions/user_steps.rb

    添加如下代码,

    Then /^应该有封激活帐号的邮件发送至<(.+)>$/ do |email|
      user = User.find_by_email(email)
      user.activation_token.should_not be_blank
      sent = ActionMailer::Base.deliveries.last
      sent.to.should eql([user.email])
      sent.subject.should =~ /激活/
      sent.body.should =~ /#{user.activation_token}/
    end

    Given /^我已经使用<(.*)//(.*)//(.*)>注册过$/ do |username, email, password|
      @valid_attributes = {
        :username              => username, 
        :email                 => email, 
        :password              => password, 
        :password_confirmation => password
      }
      @user = User.create!(@valid_attributes)
    end

    When /^我访问<(.*)>邮件中激活帐号的链接$/ do |email|
      user = User.find_by_email(email)
      visit activate_url(:token => user.activation_token)
    end

    故事用例基本上涵盖了我们开发的用意,测试代码准备就绪,还等什么,赶紧跑起来看看吖。

    运行测试,

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

    

    测试未能通过,原本应该有封激活帐号的邮件发送至<xuliicom@gmail.com>,然而却没有,因为我们还没有编写用于发送激活邮件的代码。习惯了玩测试的话,测试结果无疑对指导你的编码工作非常有帮助!

    接下来,我们就来做这些工作。


### 添加激活码字段 ###


    怎么知道用户有没有激活帐号呢?答案是在 users 表中增加用于标识用户帐号是否激活的两个字段,一个用来存放激活码,另一个用来记录帐号激活时间。假设这两个字段分别是 activation_token 和 activated_at,如果 users.activation_token 字段有值,那么就说明用户还没有激活,如果 users.activation_token 为空且 users.activated_at 有值,那么就说明用户已经激活过了。

    下面来添加这组字段,

    $ ruby script/generate migration EmailConfirm

    $ gedit db/migrate/*_email_confirm.rb

    class EmailConfirm < ActiveRecord::Migration
      def self.up
        add_column :users, :activation_token, :string
        add_column :users, :activated_at, :datetime
      end

      def self.down
        remove_column :users, :activated_at
        remove_column :users, :activation_token
      end
    end

    $ rake db:migrate

    $ rake db:test:prepare

    表结构准备完毕后,再来生成用户注册时的激活码。


### 生成激活码——activation_token ###

    $ gedit app/models/user.rb

    before_create :initialize_salt, :encrypt_password, :initialize_activation_token

    # 生成并返回标识码
    def generate_token
      encrypt(Time.now.to_s.split(//).sort_by {rand}.join)
    end

    # 生成激活码
    def initialize_activation_token
      if new_record?
        self.activation_token = generate_token
      end
    end

    数据模型搞定后,再从路由下手,需要指定控制器该如何分配响应请求。


### 配置激活帐号的路由——activate_url ###

    $ gedit config/routes.rb

    修改后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.resources :users

    end

    此时,如果你不清楚接下来要做什么;不妨运行测试,测试结果会告诉你答案。由于笔者知道会失败也知晓接下里该做什么,所以就略过此步;因为Model和Route都准备完毕,是时候动手编写业务流程了。

    如果你用Rails发过邮件,下面的步骤你一定很熟悉。


### 生成邮件 ###

    $ ruby script/generate mailer UserMailer confirm

    $ gedit app/models/user_mailer.rb

    class UserMailer < ActionMailer::Base
      
      def confirm(user, sent_at = Time.now)
        subject    '请激活您的帐号'
        recipients user.email
        from       'Admin'
        sent_on    sent_at
        
        body       :username => user.username, 
                   :url  => activate_url(:token => user.activation_token)
      end

    end

    $ gedit app/views/user_mailer/confirm.erb

    亲爱的 <%=@username%>:

        您的帐号已经创建成功,请点击下面的链接激活您的帐号:
        <%=link_to @url, @url%>


### 发送邮件 ###

    用户注册成功之后,需要发送一封确认邮件到用户注册时填写的电子邮箱中。虽然可以在 User 模型中添加 after_create 的一个回调代码来执行,但这样就给 User 模型类增添了本不应该承担的责任;我们只需要 User 模型提供数据,而不是将发送邮件的任务丢给它。这时候 ActiveRecord 提供的 Observer 就可以派上用场了,使用Observer的好处是它可以将自身连接到模型类中并注册为回调,却无需修改任务模型类的代码,我们将其称之为观察器(是否联想到Ruby设计模式中的观察者模式,呵呵)。下面,我们针对 User 模型创建一个观察器:

    $ ruby script/generate observer User

    $ gedit app/models/user_observer.rb

    class UserObserver < ActiveRecord::Observer

      def after_create(user)
        UserMailer.deliver_confirm(user)
      end

    end

    然后在 config/environment.rb 注册这个 Observer。

    $ gedit config/environment.rb

    config.active_record.observers = :user_observer

    再次发动测试引擎,看看是否working,

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

    

    由于在生成邮件那一章节里,激活链接我们用的是 link_url 这种形式,如果你知道 link_url 和 link_path 的区别,那么根据上面的测试结果,你应该了解出错的原因。如果不了解,笔者在这里补充下,link_url 会在链接中加上协议名、主机名和端口号这些;而 link_path 则不用,它会直接用根目录“/”代替之;也就是说, link_url 会在链接中加上网址;又或者说,link_url 采用绝对路径,而 link_path 采用相对路径。

    考虑到现实中的用户注册,系统会发送一封包含网址的邮件到注册用户的邮箱中,我们之前的邮件模板里不得不采用 link_url 这种形式。结合测试结果来看,也许此时您已经意识到,我们是不是忘了配置主机名呢?

    恭喜您!您确实猜对了。


### 配置邮件中激活链接的绝对路径 ###

    $ gedit app/models/user_mailer.rb

    default_url_options[:host] = HOST

    在 config/environments/test.rb 和 config/environments/development.rb 这两个配置文件中定义 HOST 常量,为了开发和测试需要,这里设置成localhost就可以了。

    HOST = 'localhost:3000'

    不过在 config/environments/production.rb 中,HOST 常量的值就必须是真实的主机名了。

    另一种方法无需修改app/models/user_mailer.rb和定义HOST常量,直接在各environment/各文件或environment.rb中配置就行了,如下代码

    $ gedit config/environment.rb

    config.action_mailer.default_url_options = { :host => 'localhost:3000' }

    这样做的好处是只需修改一处。

    好了,补上这个配置,再运行测试,看看有什么不同。

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

    


### 激活帐号 ###

    看来我们的邮件能够成功发送了,不过好像访问邮件中的确认链接时出了点问题,根据调试信息“ActionController::UnknownAction”显示,应该是没有找到激活帐号的具体行为(action)。在前面的开发中,我们真的就还没有编写响应用户激活帐号的相关代码,想必此时我们都清楚该做哪些工作了。

    我们需要给 UserController 类添加一个 Action 来响应用户激活帐号的请求。

    $ gedit app/controllers/users_controller.rb

    之前我们在config/routes.rb文件中定义了activate_path,且该activate_path 的 :action 参数指向 activate 方法;于是乎,activate 就是我们需要在 UserController 类中添加的 action。activate方法的代码如下:

    def activate
      if @user = User.find_by_activation_token(params[:token])
        if !@user.activated?
          @user.email_confirm!
          flash.now[:notice] = '恭喜您,帐号激活成功!'
        end
      end
    end

    仔细观察 UserController#activate,我们还需要在 User 模型中编写 activated? 和 email_confirm! 这两个实例方法,前者用来确认用户的帐号是否已经激活过,后者则用来激活用户的帐号。

    $ gedit app/models/user.rb

    在 protected 之前添加如下两个方法:

    # 检查是否已经激活
    def activated?
      # 当 activation_token 为 nil 时表示用户帐号已经激活
      activation_token.nil?
    end
      
    # 激活帐号
    def email_confirm!
      update_attributes(:activation_token => nil, :activated_at => Time.now)
    end

    运行测试看看,

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

    

    看来是没有找到模板文件,在此补上用户成功激活帐号的页面。

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

    保存即可。运行测试:

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

    OK,测试通过!如图,

    


### 亲临现场 ###

    最后开发人员自己别忘了手工测试,以确保万无一失。

    先清除数据库中的记录,

    $ ruby script/console
    >> User.delete_all

    假设我们以404为用户名成功注册后,我们来看看数据库中404的activation_token字段是否有值。

    >> User.find_by_username('404', :select => "username, activation_token, activated_at")

    

    可以看到,activation_token 的值是一串加密后的字符,activated_at值为空,这说明程序已经给注册用户生成了激活码,而且此时用户还没有激活帐号。

    当我们注册成功后,打开邮箱却并没有看到激活帐号的邮件,这是怎么回事呢?

    因为测试程序跑到是test环境,而我们手工测试的时候,程序是运行在development环境下的,我们没有针对development环境配置邮件服务器。下面我们采用SMTP的发信方式,这里的SMTP SERVER用的是GMAIL,而且是SSL验证登录方式;三次握手,发信速度没sendmail那么快,呵呵!

    $ gedit config/environment.rb

    config.action_mailer.delivery_method = :smtp
    config.action_mailer.default_charset = 'utf-8'

    config.action_mailer.smtp_settings = {
      :address              => 'smtp.gmail.com',
      :port                 => 25,
      :domain               => 'YOUR_DOMAIN',
      :user_name            => 'YOUR_GMAL_USERNAME',
      :password             => 'YOUR_GMAIL_PASSWORD',
      :authentication       => 'login',
      :enable_starttls_auto => true
    }

    该配置中大写部分自行替换即可。

    清空users表,我们重新注册404这个用户。

    $ ruby script/console
    >> User.delete_all

    然后去邮箱看看,

    

    这回我们打开邮箱看到了激活帐号的邮件信息,不过邮件内容中的链接标签没有生效,我们期望发送到用户邮箱的是HTML格式的邮件。ActionMailer可以让我们发送多种格式的邮件,只需要按相应的内容类型修改邮件模板的文件名格式即可。基本上,邮件模板的文件名的格式像这样:name[.content.type].renderer;content.type 可选,缺省情况下为文本格式,你也可以手工指定为 text.plain,要发送HTML格式的邮件就需要指定为 text.html;文件后缀 renderer 一般情况下都是 erb(如果你用了HAML插件,模板后缀名应该是haml)。下面我们将之前文本格式的邮件模板修改为网页形式的:

    $ mv app/views/user_mailer/confirm.erb app/views/user_mailer/confirm.text.html.erb

    再次清空users表,重新注册404这个用户,然后前往邮箱看看我们收到的邮件是否是网页格式的。

    

    我们看到激活帐号的超链接生效了(没有将超链接标签明文显示),这说明系统发送出去的确实是HTML邮件。

    接下来我们点击邮件中的链接来到了激活帐号的页面,我们看到帐号激活成功的提示信息。

    

    如果激活成功,数据库中的activation_token字段应该是空值,且activated_at字段的值应该为一时间戳;在之前的程序中,我们确实是按此逻辑编码的。虽然测试成功,而且我们也非常顺利地亲历了一遍注册流程,那么是否就说明我们的应用程序没有程序上的漏洞了吗?我们真的激活帐号了吗?我们不妨看看数据库这只黑匣子,此时应该是让数据说话的时候了。

    $ ruby script/console
    >> User.find_by_username('404', :select => "username, activation_token, activated_at")

    

    哎呀!记录居然没被更新,看来我们被表面现象给忽悠了。我想你此时也和我一样迷惑,为什么数据记录没有被更新呢?这中间到底发生了什么?这让我不由自主地想象起来,也许Rails的ORM真的修改了User实例对象的activation_token和activated_at属性的值,只不过还没有成功地写入到数据库里边而已。果真如此吗?如何证明这一说法成立呢?我们来看看User模型类的 email_confirm! 方法,下面是email_confirm! 方法的源码:

    # 激活帐号
    def email_confirm!
      update_attributes(:activation_token => nil, :activated_at => Time.now)
    end

    我们知道,update_attributes 方法还有一个和自己长得差不多一样的方法,即 update_attributes!
;后者比前者仅仅多一个感叹号而已,两者都是更新当前模型对象所指向的数据记录,只不过前者更新失败会返回false,后者更新失败则会抛出异常信息并停止程序运行,我们不妨用 update_attributes! 替换 email_confirm!方法中的update_attributes,如果问题真的出现在这里,至少我们也可以看见抛出的错误信息,这些错误调试信息对开发人员来说是那么的重要。

    $ gedit app/models/user.rb

    修改 email_confirm! 方法如下:

    def email_confirm!
      update_attributes!(:activation_token => nil, :activated_at => Time.now)
    end

    保存,然后重新访问或刷新激活帐号的页面,我们看到系统捕获到了非常实用的情报,如图:

    

    看来问题还真的出在User模型类的email_confirm!方法这里,当程序尝试更新 activation_token 和 activated_at 这两个字段时,系统告诉我们密码不能为空并就此打住,程序抛出错误并停止执行,后面当然不会更新数据库里边的记录了。找到出错的原因后,我们马上就明白 update_attributes(或update_attributes!) 会更新当前对象所指向的记录的所有字段,并在更新之前执行数据校验,如果校验失败就会打断程序的运行。想到此,针对问题的解决方案也初现轮廓,只要程序更新指定的字段,并在更新这些指定字段的时候不去校验其他字段的数据有效性就行了。OK,我们有非常适合用于email_confirm!的替代写法,不妨修改email_confirm!方法如下:

    $ gedit app/models/user.rb

    # 激活帐号
    def email_confirm!
      self.activation_token = nil
      self.activated_at = Time.now
      save(false)
    end

    上述代码中的 save 方法会更新这些字段的值,第一个参数的值指明为false后将不会执行数据校验,看来这一切和我们的想法非常吻合,不妨保存user.rb再刷新几次浏览器看看。第一次刷新和我们初次访问激活链接看到的效果一样,都是提示帐号激活成功,后面几次就看不到激活成功的消息了,因为帐号只需要激活成功一次就足够了,效果确实很理想,我们去访问下数据库让它给我们做个见证。

    $ ruby script/console
    >> User.find_by_username('404', :select => "username, activation_token, activated_at")

    

    哈哈,数据记录已经更新了,这意味着程序已经可以按照我们之前的意愿运行了。用户提交注册资料后会收到一封关于激活帐号的邮件,然后点击其中的链接可以成功激活他的帐号。

    至此,我们在 email_activation 分支上的开发工作已经顺利完成,可以将工作成果归并到主干中去了。


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

    $ git add .
    $ git commit -m 
"People can activation their accounts by the confirm emails."
    $ git checkout master
    $ git merge email_activation
    $ git branch -d email_activation
    $ git tag v2


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


### 小结 ###

    在这篇教程中,我们的开发工作遇到了不小的挫折,尤其是在人工测试那里,经过我们自己动手测试后,才知晓我们的程序漏洞百出。之所以这样,是由于笔者有意而为之,其实笔者的用意非常简单,就是想告诉开发者亲临现场做人工测试的重要性。也许确实让您受挫了,觉得好像是为了测试而测试似的;大可不必有如此想法,如果您是位Rails熟手,想必也不会犯那些低级错误,比如update_attributes和save(false)这种区别及其应用场合,也会知晓 test/development/production 这几种环境的区别;那也就避免了些不必要的麻烦。经验是慢慢积累的,过程可以帮我们汲取经验。等您自己应用熟练了,我相信您能体会到测试带来的好处。


### 下节预告 ###

    接下来我们依然是借助cucumber+rspec来驱动用户登录功能的开发,看测试跟session和cookie打交道。如果有兴趣,期待您能够下次光临!如果有好的建议和经验非常希望能够与您交流,您可以在下面发表留言或者和我email联系,我的邮箱是 xuliicom@gmail.com。

标签: Cucumber, Rails, Rspec, TDD

Posted by 404


分享给朋友:
------分隔线----------------------------
您可能感兴趣的文章:
随机阅读: