2015年9月28日
SMSインタラクションのテスト
(2015-07-27)by Joël Quenneville
本記事は、原著者の許諾のもとに翻訳・掲載しております。
SMS送信機能を含むアプリケーションを開発するにあたっては、実際にメッセージ送信を行う必要があります。その際、私たちが好んで使うのは Twilio のような外部サービスです。SMSと連携する部分の単体テストを行う時は、実際のSMS送信機能をスタブに置き換えるだけで、テストの独立性を保つことができます。では、feature specはどうなるでしょうか。
feature specの記載
次の2人のユーザの話を例にとりましょう。
購入時は確認のために、明細画面へのリンクをSMSで送ってほしい。
そして
できるだけアカウントの安全性を確保したいので、サインイン時は、事前にSMSで送信されたパスワードと4桁のコードを入力させるようにしてほしい。
2人とも、SMSメッセージによる情報のやりとりを要求しています。この場合、理想的なfeature specは以下のようになるでしょう。
feature "signing in" do
scenario "with two factors" do
user = create(:user, password: "password", email: "user@example.com")
visit root_path
click_on "Sign In"
fill_in :email, with: "user@example.com"
fill_in :password, with: "password"
click_on "Submit"
secret_code = SMS::Client.messages.last # this would be so nice
fill_in :code, with: secret_code
click_on "Submit"
expect(page).to have_content("Sign out")
end
end
テストの段階から、送信済みのメッセージにアクセスできる仕組みがあれば、どんなに楽なことでしょう。しかしSMSクライアントのライブラリには、そういった機能はありません。理由はおそらく、開発段階で送信されるメッセージを全て保存しておくのは、ただのメモリの無駄遣いだからです。
疑似SMSクライアントの作成
この場合、テスト用のSMSクライアントを独自で作成するのが望ましい解決策です。 SMSメッセージを送信してメモリに保存する代わりに、実際のクライアントのAPIを再現するのです。
例えば、 Twilio Ruby gem の公式資料を見ると、そのAPIが以下のようになっていることが分かります。
# set up a client to talk to the Twilio REST API
@client = Twilio::REST::Client.new(account_sid, auth_token)
# send an SMS
@client.messages.create(
from: '+14159341234',
to: '+16105557069',
body: 'Hey there!'
)
このAPIを再現し、疑似クライアントを作成してみましょう。
class FakeSMS
Message = Struct.new(:from, :to, :body)
cattr_accessor :messages
self.messages = []
def initialize(_account_sid, _auth_token)
end
def messages
self
end
def create(from:, to:, body:)
self.class.messages << Message.new(from: from, to: to, body: body)
end
end
ここでは以下のことを行っています。
- 初期化のロジックは一切不要。
@client.messages
は、実際のクライアント内にある新規オブジェクトを返す。疑似クライアント内にある2つ目のオブジェクトを複雑にする必要はなく、単純にself
を返すだけでよい。create
は新規Message
オブジェクトをインスタンス化し、実際にSMS送信をする代わりに、それをself.class.messages
配列に追加する。
疑似クライアントを実際のSMSクライアントの代わりに使う場合、いくつか選択肢があります。
定数のスタブ化
RSpecは、spec内に定数をスタブ化できる stub_constメソッド を提供しています。
spec helperでは次のようなことができます。
# spec/spec_helper.rb
RSpec.configure. do |config|
config.before(:each) do
stub_const("Twilio::REST::Client", FakeSMS)
end
end
こうすることで、 FakeSMS
を参照するために、かわりに Twilio::REST::Client
に別名をつけます。
Railsのローディング
定数のスタブ化がしっくりこなければ、Railのクラスローディングシステムを利用する方法があります。Railsは、既に別のものをポイントしているのでなければ、必ず定数をロードしようとするでしょう。Twilloのgemがロードされる 前に Twilio::REST::Client
を定義することで、定数の再定義に関するあらゆるエラーを避けられます。
# config/initializers/fake_twilio.rb
if Rails.env.test?
Twilio::REST::Client = FakeSMS
end
コンフィギュレーション
保持していないコードを分離するためにTwilloのコードをアダプタ内にラップした場合は、他のオプションがあります。以下のアダプタのケースを見てみましょう。
class SMSClient
def initialize
@client = Twilio::REST::Client.new(
ENV.fetch("TWILIO_ACCOUNT_SID"),
ENV.fetch("TWILIO_AUTH_TOKEN"),
)
end
異なるクライアントを受容できるよう、アダプタは簡単に変更できます。
class SMSClient
cattr_accessor :client
self.client = Twilio::REST::Client
def initialize
@client = self.class.client.new(
ENV.fetch("TWILIO_ACCOUNT_SID"),
ENV.fetch("TWILIO_AUTH_TOKEN"),
)
end
# more methods
end
そしてspec_helper内でコンフィギュレーションを変更できます。
# spec_helper.rb
SMSClient.client = FakeSMS
メッセージキューをリセットする
各テストの間に FakeSMS.messages
をリセットしたい場合は、以下のコードを spec_helper
に追加してください。
RSpec.configure do |config|
config.before :each, type: :feature do
FakeSMS.messages = []
end
end
結論
上記の選択肢のいずれかを選んで少し微調整すれば、feature specは問題なく動くはずです。
eature "signing in" do
scenario "with two factors" do
user = create(:user, password: "password", email: "user@example.com")
visit root_path
click_on "Sign In"
fill_in :email, with: "user@example.com"
fill_in :password, with: "password"
click_on "Submit"
last_message = FakeSMS.messages.last # this now returns a message object
fill_in :code, with: last_message.body # the code is the body of the message
click_on "Submit"
expect(page).to have_content("Sign out")
end
end
疑似SMSクライアントを使うやり方は、SMSメッセージとのインタラクションを要求するフローをfeature specによってテストする良い方法です。ここではTwilloのクライアントを再現する方法を紹介しましたが、このアプローチはどんなSMSプロバイダにも使えます。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa