はじめに
こんにちは!運営メンバーのなかやん(@nakayarn)といいます!前回、会員管理システムのSendGrid APIを用いたリセットメールの実装を行ったので、今回はそのテスト内容について記載させていただきます。
前回同様、会員管理システムについては@adachin0817さんのスライドを見てください!
ローカル開発環境
- バックエンド:Ruby on Rails(API)
- フロントエンド:React
- 開発環境:Docker
今回のテストに関しては、既に導入済みのRspecでテストを実施します。
Rspecとは?
RubyやRubyOnRailsの代表的なテストフレームワークです。クラスやメソッド単位でテストすることができ、単体テスト・総合テスト・システムテストなど、幅広いテストに対応できます。
実装
今回、controllerではcreate・show・updateを作成したので、以下のテストを実施。
- POST /create(リセット要求)のテスト
- GET /show(トークン検証)のテスト
- PATCH /update(パスワード更新)のテスト
また、modelにtoken_valid?メソッドを作成したので、境界値テストを実施します。
- テスト環境の準備
まずは、メールを送信テストを実施するための設定を行います。
- techbull-app/spec/requests/api/password_resets_spec.rb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
require 'rails_helper' RSpec.describe "/api/password_resets", type: :request do # travel_toを使用するための設定 include ActiveSupport::Testing::TimeHelpers # letを使用してテスト用のユーザーオブジェクトを作成 let(:user) { create(:user, email: 'test@example.com', password: 'oldpassword123') } # リセットトークンの生成 let(:reset_token) { SecureRandom.urlsafe_base64(32) } # ハッシュトークンの生成 let(:hashed_token) { Digest::SHA256.hexdigest(reset_token) } # メールテスト用のセットアップ before do # メールの送信をテストに設定することで、メールがテスト用の配列に保存される ActionMailer::Base.delivery_method = :test # メール送信処理設定 ActionMailer::Base.perform_deliveries = true # 送信されたメールを格納する配列を初期化する ActionMailer::Base.deliveries = [] end after do # 格納しているメールオブジェクトを削除 ActionMailer::Base.deliveries.clear end # ここにテストを書いていく end |
beforeを使用して、メール送信設定を行い、afterでメール内容をクリアにしています。これにより、各テストが実行された前後にbeforeとafterが実行され、各テストが独立した状態で実行されるようにしています。
- POST /create(リセット要求)のテスト
このテストでは以下の点をテストします。
- 正常系と異常系のテスト
- 存在しないメールアドレスでも成功メッセージを返すか?
- エラーハンドリングテスト
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
describe "POST /create" do subject { post "/api/password_resets", params: params, headers: { 'Content-Type' => 'application/json' } } context "有効なメールアドレスが指定された場合" do let(:params) { { email: user.email }.to_json } it "成功メッセージを返す" do subject expect(response).to be_successful expect(json_response).to match({ message: 'パスワードリセット処理が完了しました。メールをご確認ください。' }) end it "ユーザーのリセットトークンが更新される" do allow_any_instance_of(Api::PasswordResetsController).to receive(:generate_reset_token).and_return(reset_token) subject expect(user.reload.reset_password_token).to eq(hashed_token) end it "ユーザーのリセット送信日時が更新される" do travel_to Time.current do subject expect(user.reload.reset_password_sent_at).to eq(Time.current) end end it "パスワードリセットメールが送信される" do subject expect(ActionMailer::Base.deliveries.count).to eq(1) expect(ActionMailer::Base.deliveries.first.to.first).to eq(user.email) end end context "存在しないメールアドレスが指定された場合" do let(:params) { { email: 'nonexistent@example.com' }.to_json } it "セキュリティのため同じ成功メッセージを返す" do subject expect(response).to be_successful expect(json_response).to match({ message: 'パスワードリセット処理が完了しました。メールをご確認ください。' }) end it "メールは送信されない" do subject expect(ActionMailer::Base.deliveries.count).to eq(0) end end context "メール送信でエラーが発生した場合" do let(:params) { { email: user.email }.to_json } it "500エラーを返す" do allow(UserMailer).to receive(:password_reset).and_raise(StandardError.new('SMTP Error')) subject expect(response).to have_http_status(:internal_server_error) expect(json_response).to match({ message: 'メール送信に失敗しました' }) end end context "データベースエラーが発生した場合" do let(:params) { { email: user.email }.to_json } it "500エラーを返す" do allow_any_instance_of(User).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(user)) subject expect(response).to have_http_status(:internal_server_error) expect(json_response).to match({ message: 'メール送信に失敗しました' }) end end end |
トークンの更新に関しては、travel_toを使用して現在時刻を固定し、リセットパスワードを送信した日時をテストしています。travel_to
で時刻を固定することで、コード実行とテスト検証の間に生じるわずかな時間経過による誤差を防いでくれます。
- GET /show(トークン検証)のテスト
以下の点をテストしていきます。
- 有効なトークン・無効なトークン
- 期限切れトークン
- 存在しないトークン
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
describe "GET /show" do subject { get "/api/password_resets", params: { token: token }, headers: { 'Content-Type' => 'application/json' } } context "有効なトークンの場合" do let(:token) { reset_token } let(:user_with_token) { create(:user, email: 'test@example.com', password: 'oldpassword123', reset_password_token: hashed_token, reset_password_sent_at: 5.minutes.ago ) } before do user_with_token end it "トークンが有効であることを返す" do subject expect(response).to be_successful expect(json_response).to match({ message: 'トークンは有効です', email: user_with_token.email, valid: true }) end end context "無効なトークンの場合" do let(:token) { 'invalid_token' } it "422エラーを返す" do subject expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'トークンが無効または期限切れです', valid: false }) end end context "期限切れのトークンの場合" do let(:token) { reset_token } let(:user_with_expired_token) { create(:user, email: 'test@example.com', password: 'oldpassword123', reset_password_token: hashed_token, reset_password_sent_at: 15.minutes.ago ) } before do user_with_expired_token allow_any_instance_of(User).to receive(:token_valid?).and_return(false) end it "422エラーを返す" do subject expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'トークンが無効または期限切れです', valid: false }) end end context "存在しないトークンの場合" do let(:token) { 'nonexistent_token' } it "422エラーを返す" do subject expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'トークンが無効または期限切れです', valid: false }) end end end |
この中で、無効なトークン・期限切れ・存在しないトークンのテストを行なっていますが、エラーを返す際には、同じメッセージを返すようにしています(セキュリティの観点から攻撃者に情報を与えないため)。このメッセージが正しく機能するかを確認します。
また、ここではコントローラーの “Get /show” の動きをテストしたいので、token_valid?をモックして値を固定しています。
1 2 |
# token_valid?の値をtrueに値を固定する allow_any_instance_of(User).to receive(:token_valid?).and_return(true) |
(ここで記載されているtoken_valid?は後述するuser_spec.rbにテストを記載しています)
- PATCH /update(パスワード更新)のテスト
このテストでは以下の点をテストします。
- パスワードの一致確認
- パスワードが要件を満たしているかのチェック
- トークンのクリア確認
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
describe "PATCH /update" do subject { patch "/api/password_resets/", params: params, headers: { 'Content-Type' => 'application/json' } } let(:new_password) { 'newpassword123' } let(:params) do { token: token, user: { password: new_password, password_confirmation: new_password } }.to_json end context "有効なトークンと有効なパスワードの場合" do let(:token) { reset_token } let(:user_with_token) { create(:user, email: 'test@example.com', password: 'oldpassword123', reset_password_token: hashed_token, reset_password_sent_at: 5.minutes.ago ) } before do user_with_token allow_any_instance_of(User).to receive(:token_valid?).and_return(true) end it "パスワードが更新される" do subject expect(response).to be_successful expect(json_response).to match({ message: 'パスワードが正常に更新されました' }) end it "リセットトークンがクリアされる" do subject user_with_token.reload expect(user_with_token.reset_password_token).to be_nil expect(user_with_token.reset_password_sent_at).to be_nil end it "新しいパスワードでログインできる" do subject user_with_token.reload expect(!!user_with_token.authenticate(new_password)).to be true end end context "無効なトークンの場合" do let(:token) { 'invalid_token' } it "422エラーを返す" do subject expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'トークンが無効または期限切れです' }) end end context "期限切れのトークンの場合" do let(:token) { reset_token } let(:user_with_expired_token) { create(:user, email: 'test@example.com', password: 'oldpassword123', reset_password_token: hashed_token, reset_password_sent_at: 15.minutes.ago ) } before do user_with_expired_token allow_any_instance_of(User).to receive(:token_valid?).and_return(false) end it "422エラーを返す" do subject expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'トークンが無効または期限切れです' }) end end context "パスワード確認が一致しない場合" do let(:token) { reset_token } let(:params) do { token: token, user: { password: new_password, password_confirmation: 'different_password' } }.to_json end let(:user_with_token) { create(:user, email: 'test@example.com', password: 'oldpassword123', reset_password_token: hashed_token, reset_password_sent_at: 5.minutes.ago ) } before do user_with_token allow_any_instance_of(User).to receive(:token_valid?).and_return(true) end it "422エラーを返す" do subject expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'パスワード更新に失敗しました', errors: include(match(/Password confirmation.*doesn.*match/)) }) end end context "パスワードが短すぎる場合" do let(:token) { reset_token } let(:user_with_token) { create(:user, email: 'test@example.com', password: 'oldpassword123', reset_password_token: hashed_token, reset_password_sent_at: 5.minutes.ago ) } before do user_with_token allow_any_instance_of(User).to receive(:token_valid?).and_return(true) end it "422エラーを返す" do patch "/api/password_resets", params: { token: token, user: { password: '123', password_confirmation: '123' } } expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to match({ message: 'パスワード更新に失敗しました', errors: include(match(/Password is too short/)) }) end end end end |
ここではトークンとパスワード関連の確認を行うため、冒頭で作成したreset_token
とhashed_token
を使用してテストしていきます。なお、既にユーザーにトークンが送付されている後なので、エラーメッセージはユーザビリティを考えて詳細な内容を出力しています。
- token_valid?(有効期限チェック)のテスト
このテストでは以下の点をテストします。
- 境界値テスト(ちょうど10分前と10分1秒前の場合)
- techbull-app/spec/models/user_spec.rb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
require 'rails_helper' RSpec.describe User, type: :model do 〜省略〜 describe '#token_valid?' do context 'reset_password_sent_atがnilの場合' do it 'falseを返す' do user = FactoryBot.create(:user, reset_password_sent_at: nil) expect(user.token_valid?).to be false end end context 'reset_password_sent_atが10分以内の場合' do it 'trueを返す' do user = FactoryBot.create(:user, reset_password_sent_at: 5.minutes.ago) expect(user.token_valid?).to be true end it '境界値テスト: ちょうど10分前の場合はtrueを返す' do travel_to Time.current do user = FactoryBot.create(:user, reset_password_sent_at: 10.minutes.ago) expect(user.token_valid?).to be true end end end context 'reset_password_sent_atが10分を超えている場合' do it 'falseを返す' do user = FactoryBot.create(:user, reset_password_sent_at: 15.minutes.ago) expect(user.token_valid?).to be false end it '境界値テスト: 10分1秒前の場合はfalseを返す' do user = FactoryBot.create(:user, reset_password_sent_at: 10.minutes.ago - 1.second) expect(user.token_valid?).to be false end end end end |
有効期限が切れている場合とそうでない場合のテストを行なっています。その際に、トークンの有効期限のギリギリの「ちょうど10分前」と「10分1秒」の場合をテストしています。
テストの実行
テストの実行は以下のコマンドで実行します。
1 |
$ docker compose exec app bundle exec rspec |
画像のように failures が 0 になっていればテストは通っています。
おわりに
今回、リセットメールのテストを書くことで、travel_to
を使用した時間固定や境界値テストについて学ぶことができました。今回は、機能実装を先に行なってしまったのですが、今後はTDD(テスト駆動開発)を実践し、次からはテストファーストを心がけようと思います。
最後まで読んでいただき、ありがとうございました!

@adachin0817さんとはエンジニアイベントを通じて知り合い、コミュニティに参加。現在は、未経験エンジニア向けの研修や、プログラミングスクールでの質問対応・就職支援を担当。メインの開発言語はRuby on Railsで、時々Javaも使用。サービス全体の構造を理解したいという思いから、フロントエンドやクラウドなど幅広い分野を横断的に学習中。