When faking external services in tests, I like to start with a simple solution and only progress to a more complex one if I need it. That’s why I usually start with a simple class that acts as the public interface to adapters and make an in-memory adapter for tests. Let’s see a concrete example.
Suppose we need to use Twilio’s SMS service. We don’t want to make a request to Twilio’s api every time we send a message in a test, and in some tests, we’d like to assert that the correct messages are being “sent”.
Let’s first create an
SmsClient class that will be the interface for the rest
of our code to send messages.
class SmsClient cattr_accessor :adapter def initialize @client = adapter.new end def send_message(args) @client.send_message(args) end end
cattr_accessor (that comes with Rails) to have access to a
SmsClient.adapter reader and writer methods. If you’re not using Rails, we can
instead add an
attr_accessor to the eigenclass like this:
class SmsClient class << self attr_accessor :adapter end def initialize @client = self.class.adapter.new end def send_message(args) @client.send_message(args) end end
The rest of the code is fairly simple. We instantiate our adapter in the
initializer and delegate calls to the
Now to our adapters:
module SmsAdapters class Twilio def initialize # initialize twilio client with credentials end def send_message(args) # send real message to twilio end end end
module SmsAdapters class InMemory cattr_accessor :messages self.messages =  def send_message(message) self.class.messages << OpenStruct.new(message) end end end
We use another
cattr_accessor in our in-memory adapter for ease of collecting
messages at the class level, but we could have just as easily used an
attr_accessor :messages on the eigenclass.
The behavior is much like ActionMailer::Base.deliveries — during tests, action mailer simply collects all emails in an array instead of sending them. In a similar way, our in-memory adapter collects an array of messages instead of sending them.
We can now set the default adapter to be the Twilio adapter:
class SmsClient cattr_accessor :adapter + self.adapter = SmsAdapters::Twilio def initialize @client = adapter.new end def send_message(args) @client.send_message(args) end end
And we only swap it with our in-memory adapter in tests:
# spec/rails_helper.rb RSpec.configure do |config| config.around do |example| # keep old adapter to use after test runs old_adapter = SmsClient.adapter # set our in-memory adapter SmsClient.adapter = SmsAdapters::InMemory # run the test example.run # put back the previous adapter SmsClient.adapter = old_adapter end end
Don’t forget to clear that array of messages before every test run. We don’t want some tests polluting other tests’ data and causing intermittent failures.
# spec/rails_helper.rb RSpec.configure do |config| config.before do SmsAdapters::InMemory.clear_messages end end
Let’s add that
.clear_messages method to our
module SmsAdapters class InMemory cattr_accessor :messages self.messages =  + + def self.clear_messages + self.messages =  + end def send_message(message) self.class.messages << OpenStruct.new(message) end end end
Nicely done! Now, all tests that tangentially interact with the
which do not care about the sent messages) continue running normally, being none
the wiser. And when we want to assert that an SMS message has been “sent” in an
integration test, we can simply query the in-memory adapter:
it 'sends an sms when opening new account' do user = create(:user, phone: "+15552223333") OnboardUser.run(user) sent_messages = SmsClient.adapter.messages expect(sent_messages.last).to have_attributes(to: user.phone, body: "Welcome!") end
If we also decide not to send SMS messages in development, we can easily create a new adapter that uses local storage — similar to how ActiveStorage’s local adapter just saves attachments to disk.
module SmsAdapters class Local def send_message(message) # save_to_file end end end
We could even expose those messages through an
/sms_previews route in
development. Nice and easy!
If you don’t like the in-memory type of adapters, or if you find your setup requires something more complex, these blog posts are a good guide on setting up fakes with local servers using Sinatra: