Remember tests

雖然 Remember me 的功能已經可以運作了,但還是要寫個測試來檢查它的運作。測試可以捕捉實作時可能產生的錯誤,另外就是我們還沒針對持久性 sessions 做測試。

Testing the “remember me” box

在處理 checkbox 的時候,程式碼如下:

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

但最初作者寫成這樣:

params[:session][:remember_me] ? remember(user) : forget(user)

params[:session][:remember_me] 的值不是 0 就是 1,都是真值,所以都會回傳 true,這表示 APP 會一直以為勾選了 checkbox,這就是測試能捕捉的問題點。

因為記住使用者的條件就是必須為登入狀態,我們的第一步就是建立一個輔助方法,放進測試裡,作用是要登入使用者。

在之前,我們透過使用 post 方法和一個 session hash 來登入使用者進行測試:

post login_path, session: { email: @user.email, password: 'password' }

但如果每次都要重複這樣寫太麻煩,所以我們要定義一個 log_in_as 輔助方法來處理登入。而這個方法會取決於測試的類型,在整合測試裡面,我們可以使用 post 方法發送 session hash 登入使用者,不過在其他類型的測試,例如 controller test 和 model test,就無法這樣做了,必須直接使用 session 方法。

所以 log_in_as 輔助方法必須進行判斷的動作,針對不同測試類型,去調用適合的方法。這時候可以使用 Ruby 的 defined? 方法來區分整合測試或其他類型的測試。defined? 的參數如果有被定義,會回傳 true,反之回傳 false

例如,前面的 post_via_redirect 方法只在整合測試中有用,所以:

defined?(post_via_redirect) ...

會在整合測試中回傳 true,在其他類型的測試回傳 false

由此,我們可以定義一個 integration_test? 布林值方法,然後使用 if..then 述句撰寫下列程式碼:

if integration_test?
  # Log in by posting to the sessions path
else
  # Log in using the session
end

把上面的註解換成程式碼之後的 log_in_as 輔助方法如下:

test/test_helper.rb

ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # Returns true if a test user is logged in.
  def is_logged_in?
    !session[:user_id].nil?
  end

  # Logs in a test user.
  def log_in_as(user, options = {})
    password    = options[:password]    || 'password'
    remember_me = options[:remember_me] || '1'
    if integration_test?
      post login_path, session: { email:       user.email,
                                  password:    password,
                                  remember_me: remember_me }
    else
      session[:user_id] = user.id
    end
  end

  private

    # Returns true inside an integration test.
    def integration_test?
      defined?(post_via_redirect)
    end
end

注意,為了更有彈性,log_in_as 方法接受一個 options hash,而且為 password 和 remember me 設定預設值,分別是「password」和「1」。

因為在 hash 中,如果 key 不存在,就會回傳 nil,所以:

remember_me = options[:remember_me] || '1'

如果傳入了參數,就使用那個值,否則就用預設值。

為了檢查 remember me 的 checkbox 行為,我們會進行兩個測試,一個就是有勾選,另一個沒勾選。所以定義了上面的 log_in_as 輔助方法,就可以很輕鬆的拿來進行測試:

log_in_as(@user, remember_me: '1')
log_in_as(@user, remember_me: '0')

其實參數 1 因為是預設值,所以可以省略,只是寫出來脈絡會更清楚。


登入使用者之後,就要透過查找 cookies 裡面有沒有 remember_token 這樣的 key 存在,來檢查使用者是否有被記住。理想情況下,可以檢查 cookie 中的值是否等於使用者的 remember token,但對目前的設計方式來說,無法執行。因為在 controller 中的 user 變數有 remember_token 屬性,但在測試中的 @user 變數卻沒有該屬性(因為該屬性是虛擬屬性)。

這個小缺陷留在練習題實作。現在我們只測試 cookie 相關的值是否為 nil

還有個小問題,基於某種原因在測試裡面,cookies 方法無法使用 symbol 作為 key:

cookies[:remember_token]

這樣的結果,會總是為 nil。還好,cookies 方法可以接受字串為 key:

cookies['remember_token']

就可以找到我們要的 key 和 value。

最後測試程式碼如下:

test/integration/users_login_test.rb

require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_nil cookies['remember_token']
  end

  test "login without remembering" do
    log_in_as(@user, remember_me: '0')
    assert_nil cookies['remember_token']
  end
end

現在測試應該可以通過(Green):

$ bundle exec rake test

Testing the remember branch

在之前我們手動實作了可以正常使用的持久性 session,但是 current_user 方法的相關分支(relevant branch)還沒有測試過。針對這種情況,可以在未測試的程式碼中拋出例外(raise an exception),如果沒有涵蓋這部分的測試程式碼,測試能通過,如果涵蓋了,失敗的訊息中會標示出相對應的測試。

在未測試的程式碼中拋出例外:

app/helpers/sessions_helper.rb

module SessionsHelper
  .
  .
  .
  # Returns the user corresponding to the remember token cookie.
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      raise       # The tests still pass, so this branch is currently untested.
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

現在執行測試,會顯示通過(Green):

$ bundle exec rake test

所以表示有問題,上述的程式碼簡單來說壞掉了。此外,手動測試持久性 session 很麻煩,所以如果之後要重構 current_user 的話,現在就要先寫個測試。

因為剛剛定義的 log_in_as 輔助方法會自動設定 session[:user_id],所以在整合測試中要測試 current_user 方法的「remember」分支很難。還好,我們可以跳過這個限制,透過在 Sessions helper test 直接測試 current_user 方法,為此我們要建立一個檔案:

$ touch test/helpers/sessions_helper_test.rb

測試的步驟很簡單:

  1. 透過 fixtures 定義 user 變數
  2. 調用 remember 方法來記住這個使用者
  3. 檢查 current_user 是否和這個使用者相等

因為 remember 方法沒有定義 session[:user_id],所以上述步驟能測試「remember」分支。

測試持久性 session:

test/helpers/sessions_helper_test.rb

require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

  def setup
    @user = users(:michael)
    remember(@user)
  end

  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end

注意,上面還寫了第二個測試,檢查如果 remember digest 和 remember token 不匹配時,目前使用者會是 nil。這是要測試 authenticated? 方法裡面的 if 巢狀述句:

if user && user.authenticated?(cookies[:remember_token])

順便說一下,上述程式碼中的 assert_equal @user, current_user 也可以寫成:

assert_equal current_user, @user

不過按照慣例,assert_equal 的參數順序習慣使用 expected, actual

assert_equal <expected>, <actual>

現在執行測試,可以得到我們想要的失敗測試(Red):

$ bundle exec rake test TEST=test/helpers/sessions_helper_test.rb

所以我們要把 current_user 方法恢復原樣,刪除 raise,這樣測試就會通過了(Green):

app/helpers/sessions_helper.rb

module SessionsHelper
  .
  .
  .
  # Returns the user corresponding to the remember token cookie.
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

再度執行測試,顯示通過(Green):

$ bundle exec rake test

現在 current_user 的「remember」分支已經有了測試,不用手動檢查了,還能進行測試和捕捉 regressions。