Testing SMS Interactions

Joël Quenneville

When building an application that sends SMS, we like to use an external service such as Twilio to handle the actual sending of the messages. When unit testing parts of code that interact with SMS, you can simply stub out the actual sending of SMS to keep your test isolated. But what about feature specs?

Writing feature specs

Take the following two user stories:

When I make a purchase, I want to receive a link to an invoice via SMS as confirmation.

and

When I sign in, I want be asked for both my password and for a four-digit code sent to me via SMS to ensure greater security for my account.

They both require us to interact with an SMS message. Ideally, a feature spec would look something like:

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

If we had some mechanism for accessing sent messages from the tests that would make things much easier. SMS client libraries don’t provide this functionality because it would be a pointless waste of memory to store all messages sent in production.

Building a fake SMS client

A good solution here is to create our own test SMS client that mimics the API of the real client but instead of sending an SMS message stores the message in memory.

For example, looking at the documentation for the official Twilio Ruby gem we can see that its API is the following:

# 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!'
)

Let’s build a fake client that mimics this 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

There are a few things going on here:

  • We don’t need any logic for initialization
  • @client.messages returns a new object in the real client. No need to add the complexity of a second object in the fake so we just return self.
  • create instantiates a new Message object and appends it to the self.class.messages array instead of actually sending SMS.

There are a few options when it comes time to use the fake SMS client instead of the real one.

Stubbing the constant

RSpec offers a stub_const method that allows us to stub constants in specs.

In our spec helper we could do:

# spec/spec_helper.rb

RSpec.configure. do |config|
  config.before(:each) do
    stub_const("Twilio::REST::Client", FakeSMS)
  end
end

This aliases Twilio::REST::Client to refer to FakeSMS instead.

Rails loading

If stubbing constants makes you uncomfortable, you can take advantage of Rails’ class loading system. Rails will only try to load a constant if it isn’t already pointing to something. By defining Twilio::REST::Client before the Twilio gem is loaded, you avoid any constant redefinition errors.

# config/initializers/fake_twilio.rb

if Rails.env.test?
  Twilio::REST::Client = FakeSMS
end

Configuration

If you wrapped the Twilio code in an adapter to isolate the code you don’t own you have another option. Given the following adapter:

class SMSClient
  def initialize
    @client = Twilio::REST::Client.new(
      ENV.fetch("TWILIO_ACCOUNT_SID"),
      ENV.fetch("TWILIO_AUTH_TOKEN"),
    )
  end

  def send_message(from:, to:, body:)
    @client.messages.create(from: from, to: to, body: body)
  end
end

The adapter can easily be modified to allow it to accept different clients:

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

Then in the spec_helper we can change the config:

# spec_helper.rb

SMSClient.client = FakeSMS

Reseting the messages queue

You may want to reset FakeSMS.messages between each test. To do so, add the following to your spec_helper:

RSpec.configure do |config|
  config.before :each, type: :feature do
    FakeSMS.messages = []
  end
end

Conclusion

Once we pick one of the options above, our feature spec should run successfully after making a few tweaks.

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"

    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

Using a fake SMS client is a clean way to allow feature specs to test flows that require interaction with SMS messages. Although we looked at mimicking Twilio’s client here, this approach can be used for any SMS provider.