How to handle remote services in tests
Do you have difficulties in adding the new tests and their readability decreased due to mocks and stubs? Let’s try to get rid of external requests in tests. #
The main idea is to override implementation dynamically during the call of external service. In other words, to use different sources for receiving data for different environments. Suppose for production environments, you get the data from a third-party server, and for a test environment, the source can simply return an object of the desired format.
Bridge Pattern: class with abstraction #
First of all, we separate responsibilities in different classes. According to the Bridge pattern, it needs to decouple an abstraction from its implementation.
Here we implement the abstraction with the external service call, and here we will introduce the dependency:
class Medium
cattr_accessor :source
def initialize(name = nil)
@client = source.new(name)
end
def posts
@client.user_posts
end
end
cattr_accessor: source allows us to determine which class will participate in the loading of posts.
Bridge Pattern: the Implementators #
We need to have implementations: one for the real external service call, another will be called in tests.
Call to real API will look like:
module PostsSource
class Remote
def initialize(username)
@client = MediumAPI.new(username)
end
def user_posts
@client.posts
end
end
end
And the fake implementation:
module PostsSource
class Fake
def initialize(username)
@client = OpenStruct.new(
posts: [
{
title: 'Signal v Noise exits Medium[Fake source]',
...
}
]
)
end
def user_posts
@client.posts
end
end
end
Note: All source implementations must have the same interface.
How does it work #
When we need to use the external service:
posts = Medium.new('dhh')
posts.source = PostsSource::Remote
posts.user_posts
To use Fake implementation:
posts = Medium.new
posts.source = PostsSource::Fake
posts.user_posts
Settings for various environments #
According to Ruby on Rails way, the settings for various environments need to be placed in the initializer.
First, let’s set the default value for the source:
class Medium
cattr_accessor :source
self.source = PostsSource::Remote
…
end
For the convenience of testing the code associated with the Medium class, you can make a separate class that will manage the source attribute.
require 'posts_source/fake'
class Medium::Testing
def self.fake!
Medium.source = PostsSource::Fake
end
end
Now in the initializers folder, let’s create the file with necessary configurations:
# frozen_string_literal: true
require 'posts_source/remote'
require 'medium/testing'
Medium::Testing.fake! if Rails.env.test?
In this way PostsSource::Fake will
be used for the test environment and PostsSource::Medium
the other environment.
Benefits received #
The real response still needs to be tested, and most likely, it will be stubbed. But dependency injection allows decreasing the number of stubbing usages in the tests.
Paul Keen is an Open Source Contributor and a Chief Technology Officer at JetThoughts . Follow him on LinkedIn or GitHub .
If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories .