LoginSignup
KH14
@KH14 (Kaho H)

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

【Rspec】メール認証を使ったユーザー登録のテストについてアドバイスをお願いいたします

解決したいこと

Ruby on Railsでオリジナルアプリを作成中です。
Railsチュートリアルに倣ってUserモデルを作成し、
Everyday Railsを拾い読みしながらRspecでテストを書いていますが
テストが通らず詰まっています。

具体的には、メール認証を使ったユーザー登録のテストをシステムスペックで書いていますが、
下記内容でテストが失敗しています。

Failures:

  1) Users User CRUD creating a new user creates a new user
     Failure/Error: visit edit_user_account_activation_path(@user.activation_token, email: @user.email)

     ActionController::UrlGenerationError:
       No route matches {:action=>"edit", :controller=>"user_account_activations", :email=>"test@example.com", :id=>nil}, possible unmatched constraints: [:id]
       Did you mean?  edit_user_account_activation_url

ブラウザで手動で動かしてみると期待通りの動きをしているので、
ルーティングや他のコードが間違っているわけではなさそうだと考えており、
テストの理解が不足していてその書き方が誤っているのだと思うのですが、
原因と修正すべき点や確認すべき事項などアドバイスいただけますと幸いです。

環境:ローカル環境

version
macOS Big Sur 11.2.3
Ruby 3.0.0
Rails 6.1.3
Rspec 3.10
(rspec-rails) 5.0.1
capybara 3.26

該当するソースコード

users_spec.rb
require 'rails_helper'

RSpec.describe 'Users', js: true, type: :system do
  describe 'User CRUD' do
    let(:user) { FactoryBot.build(:user) }
    let(:user_not_activated) { FactoryBot.build(:user, activated: false) }
    let(:another_user) { FactoryBot.create(:user, email: 'another@example.com') }
    before do
      @number_of_users = User.count
      ActionMailer::Base.deliveries.clear
    end

    context 'creating a new user' do
      it 'creates a new user' do
        visit signup_path
        fill_in '名前', with: user_not_activated.name
        fill_in 'メールアドレス', with: user_not_activated.email
        fill_in 'パスワード', with: user_not_activated.password
        fill_in 'パスワード(確認)', with: user_not_activated.password_confirmation
        click_button 'ユーザー登録'

        expect(ActionMailer::Base.deliveries.size).to eq 1

        @user = User.find_by(email: user_not_activated.email)
        expect(@user).to_not be_activated

        visit edit_user_account_activation_path(@user.activation_token, email: @user.email)
        @user.reload
        expect(@user).to be_activated
        expect(get_me_the_cookie('remember_token')).to_not eq nil
        expect(page).to have_current_path edit_user_url(@user)
        expect(page).to have_content 'ようこそ!'

        expect(User.count).to eq @number_of_users + 1
      end

#以下略
user.rb
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token

  before_save   :downcase_email
  before_create :create_activation_digest
  validates :name, presence: true, length: { maximum: 50 }

  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: true

  VALID_PASSWORD_REGEX = /\A(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)\w{8,12}\z/
  has_secure_password
  validates :password, presence: true,
                       format: { with: VALID_PASSWORD_REGEX, message: 'は半角8~12文字で英大文字・小文字・数字それぞれ1文字以上含む必要があります' },
                       allow_nil: true

  # 渡された文字列のハッシュ値を返す
  def self.digest(string)
    cost = if ActiveModel::SecurePassword.min_cost
             BCrypt::Engine::MIN_COST
           else
             BCrypt::Engine.cost
           end
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def self.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(attribute, token)
    digest = send("#{attribute}_digest")
    return false if digest.nil?

    BCrypt::Password.new(digest).is_password?(token)
  end

  def forget
    update_attribute(:remember_digest, nil)
  end

  private

  def downcase_email
    email.downcase!
  end

  def create_activation_digest
    self.activation_token  = User.new_token
    self.activation_digest = User.digest(activation_token)
  end
end

users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: %i[show edit update destroy]
  before_action :correct_user, only: %i[edit update destroy]
  def new
    @user = User.new
    return unless logged_in?

    redirect_to root_url
  end

  def create
    @user = User.new(user_params)
    if @user.save
      UserMailer.account_activation(@user).deliver_now
      flash[:info] = 'アカウント認証メールを確認して登録を完了してください'
      redirect_to root_url
    else
      render 'new'
    end
  end

#以下略
user_account_activations_controller.rb
class UserAccountActivationsController < ApplicationController
  def edit
    user = User.find_by(email: params[:email])
    if user && !user.activated? && user.authenticated?(:activation, params[:id])
      user.update_attribute(:activated, true)
      user.update_attribute(:activated_at, Time.zone.now)
      log_in user
      flash[:success] = 'ようこそ!'
      redirect_to edit_user_url user
    else
      flash[:danger] = 'このURLは無効です'
      redirect_to root_url
    end
  end
end

自分で試したこと

pメソッドで@userの中身を確認しました。

users_spec.rb
#これより前省略
  context 'creating a new user' do
      it 'creates a new user' do
        visit signup_path
        fill_in '名前', with: user_not_activated.name
        fill_in 'メールアドレス', with: user_not_activated.email
        fill_in 'パスワード', with: user_not_activated.password
        fill_in 'パスワード(確認)', with: user_not_activated.password_confirmation
        click_button 'ユーザー登録'

        @user = User.find_by(email: user_not_activated.email)
        expect(ActionMailer::Base.deliveries.size).to eq 1
        expect(@user).to_not be_activated

        p @user.activation_token # <---- デバッグ用に追記
        p @user # <---- デバッグ用に追記
        visit edit_user_account_activation_path(@user.activation_token, email: @user.email)
#これより後省略

その結果、(エラーメッセージにも出ていた通りですが)
@user.activation_tokenの中身がnilとなっており、これが原因だと考えているのですが
なぜnilになってしまうのかがわかりません。
@user.activation_tokenから生成される@user.activation_digestには値が入っていました)

その他情報の過不足ありましたらご教示ください。
よろしくお願いいたします。

1

5Answer

@uasi さんの返信を少し具体化した回答を投稿してみます。

メールの本文がこのようになっていたと仮定します。

こんにちは。

https://example.com/user_account_activations/b4GOKm4pOYU_-BOXcrUGDg/edit?email=hoge@example.com

さようなら。

この場合、以下のようなコードでtokenだけを抜き出すことができます。

email = ActionMailer::Base.deliveries.last
body = email.body.encoded
token = body[/(?<=user_account_activations\/)[^\/]+/]
p token
#=> "b4GOKm4pOYU_-BOXcrUGDg"

tokenが取得できたら、次のようにしてvisitできるはずです。

visit edit_user_account_activation_path(token, email: @user.email)

tokenの取得には正規表現を使っています。
正規表現が苦手な場合は以下の連載の1〜4を読んでみてください。

初心者歓迎!手と目で覚える正規表現入門・その1「さまざまな形式の電話番号を検索しよう」 - Qiita

また、題材は少し異なりますが、以下の記事でも同じようにメール本文からtokenを抜き出すコード例を書いています。

Devise confirmable用のテスト(フィーチャスペック)を書く(解説動画付き) - Qiita

以上、ご参考までに。

2
class User < ApplicationRecord
  attr_accessor :remember_token, :activation_token

この attr_accessoractivation_token メソッドを上書き定義しているせいだと思います。これでは @user.activation_token がデータベース上の値ではなく User のインスタンス変数 @activation_token の初期値 nil を返すことになります。

attr_accessor :remember_token, :activation_token 行は不要なはずなので消してみてください。もし users テーブルに remember_tokenactivation_token カラムがなければマイグレーションで追加してください。

1

失礼しました。確かに activation_token はメールで送るだけでデータベースに保存するべきではありませんでしたね。

ActionMailer::Base.deliveries.last で最後に送ったメールオブジェクトが取得できるので、その body メソッドで本文を得るか、 instance_variable_get でメールのインスタンス変数を取り出して、そこからトークンを抜き出し、 edit_user_account_activation_path に与えるといいのではないかと思います。

1

Comments

  1. @KH14

    Questioner
    ご返信ありがとうございます。
    いえ、こちらの説明不足でかえって申し訳ありませんでした。

    instance_variable_getの使い方がいまいちわかっておらず、
    今回はアドバイスいただいたようにメール本文からトークンを抜き出し、無事テストをパスさせることができました!ありがとうございました。

@jnchito
ご回答ありがとうございます!
ちょうど @uasi 様からいただいたアドバイスを受けて、
チェリー本と正規表現入門のqiitaの記事を拝読した際のメモを片手に正規表現を書いていたところでしたので、その著者の方からご回答いただけて嬉しい驚きでした。

@uasi 様からのアドバイスを受けて、

mail = ActionMailer::Base.deliveries.last
token = mail.body.encoded.match(/(?<=user_account_activations\/).+(?=\/)/)

と書いていましたが、なるほどご指摘いただいた書き方の方が短くスッキリかけるのですね。勉強になりました。

またちょうどDeviseを使う方法で作り直そうかと思っていましたので、
ご提示いただいた記事も確認させていただきます。ありがとうございました。

1

@uasi

早速ご回答いただきありがとうございます。
なるほど、findではデータベース上からユーザーを取得してしまうため、
nilが返ってくるということですね。初歩的な認識が抜けておりました・・・。

今回attr_accessorを使用しているのは敢えてそうなっております。
usersテーブルにはactivation_tokenから生成されたactivation_digestを保存し、
認証の際、そのactivation_tokenactivation_digestが同じものかを確認します。
そのため、activation_tokenはテーブルに保存しないようにしたいのです。
ですので今回は

@user = User.find_by(email: user_not_activated.email)

の箇所を変更してassignsメソッド(は非推奨ですが、、、)もしくはinstance_variable_getなどを使ってインスタンス変数を取得する方法で模索してみようと思います。

0

Your answer might help someone💌