DEV Community

Cover image for How to handle remote services in tests
JetThoughts Dev for JetThoughts

Posted on • Edited on • Originally published at jetthoughts.com

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

To use Fake implementation:

posts = Medium.new
posts.source = PostsSource::Fake
posts.user_posts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)