SMSインタラクションのテスト

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プロバイダにも使えます。