Don't Test Implementation, Use Dependency Injection

A common pattern I've seen in some tests that make calls to external services is to mock the specific library that is making the HTTP request. For example:

describe SlackService do
  let(:slack_service) { SlackService.new(url: "http://www.example.com") }

  it "posts to http" do
    allow_any_instance_of(HTTPClient).to receive(:post).and_return("response")
    response = slack_service.send_notification("hello")

    expect(response).to eq("response")
  end
end

Here is the corresponding class:

class SlackService
  DEFAULT_CHANNEL = "#app-alerts"

  def initialize(url:)
    @url = url
  end

  attr_reader :url

  def send_notification(msg, title = "", channel = DEFAULT_CHANNEL)
    return unless url && (aws_env != "uat")

    slack_msg = format_slack_msg(msg, title, channel)

    params = { body: slack_msg.to_json, headers: { "Content-Type" => "application/json" } }
    http_service.post(url, params)
  end

  private

  def http_service
    HTTPClient.new
  end

  def format_slack_msg(msg, title, channel)
    channel.prepend("#") unless channel =~ /^#/

    {
      username: "MyApp (#{aws_env})",
      channel: channel,
      attachments: [
        {
          title: title,
          color: "#ccc",
          text: msg
        }
      ]
    }
  end

  def aws_env
    ENV.fetch("DEPLOY_ENV", "development")
  end
end

Mocking HTTPClient makes the test brittle because if we change SlackService to use a different library, such as Faraday, then we will have to change our tests. The particular library used to make HTTP calls is an implementation detail. It should not affect the behavior of the class. We should be able to swap out any HTTP library and have our tests continue to pass without changing them. How do we do that? With dependency injection!

One way is to allow specifying the HTTP library as an argument. For example:

class SlackService
  def initialize(url:, http_service: HTTPClient.new)
    @url = url
    @http_service = http_service
  end

  private

  attr_reader :url, :http_service

  ...
end

We specify a default library to use if one is not passed. This allows us to pass in a fake HTTP library in our tests:

# spec/support/fake_http_library.rb
class FakeHttpLibrary
  def post(_url, _params); end
end

We can then use it like this:

describe SlackService do
  it "posts to http" do
    http_library = FakeHttpLibrary.new
    slack_service = SlackService.new(url: "http://www.example.com", http_service: http_library)
    allow(http_library).to receive(:post).and_return("response")
    response = slack_service.send_notification("hello")

    expect(response).to eq("response")
  end
end

Now we can use any HTTP library that responds to post (all the popular ones do) without having to change our tests. We also got rid of allow_any_instance_of, which is discouraged.

In a future post, we will further improve the test and SlackService class.