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 returnself
.create
instantiates a newMessage
object and appends it to theself.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.