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?
Take the following two user stories:
When I make a purchase, I want to receive a link to an invoice via SMS as confirmation.
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: "email@example.com") visit root_path click_on "Sign In" fill_in :email, with: "firstname.lastname@example.org" 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.
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.messagesreturns a new object in the real client. No need to add the complexity of a second object in the fake so we just return
createinstantiates a new
Messageobject and appends it to the
self.class.messagesarray 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.
RSpec offers a
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
Twilio::REST::Client to refer to
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
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
You may want to reset
FakeSMS.messages between each test. To do so, add the
following to your
RSpec.configure do |config| config.before :each, type: :feature do FakeSMS.messages =  end end
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: "email@example.com") visit root_path click_on "Sign In" fill_in :email, with: "firstname.lastname@example.org" 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.