Mocking out network requests by faking HTTP

August 14, 2023 · View on GitHub

Mocktail does nothing to make faking HTTP requests easy. And if you care to try, know that it also does nothing to ensure mocked requests behave consistently across various HTTP libraries and gems (Net::HTTP, faraday, httparty, etc.). In fact, other gems are designed to do this and only this, most notably webmock.

But, if you're reading this, there's a small chance you're thinking about faking out the network in the context of something you're conceiving of as a unit test, and that's why you're looking to your mocking library for an answer. If that's the case, here's a quick note on why you might be trying to apply the wrong solution to the problem.

Suppose you're writing a unit test and trying to control for the network and thinking about using a library like Mocktail as a result. To illustrate: imagine you're testing a method that makes a network request and does one thing if the response succeeds and another thing to handle failures.

You might want to write these two test cases:

def test_success
  result = @subject.hack_computer("/school_sprinklers")

  assert_equal "Mess with the best, die like the rest", result
end

def test_failure
  assert_raises(CrashOverrideError) do
    @subject.hack_computer("/the_gibson")
  end
end

But you may not have an easy way to force the first test case to always exercise the happy path in which the request succeeds. Similarly, you would need a way to ensure the other test case to consistently travels the sad path wherein the request fails. Short of spinning up a fake HTTP service, you don't have a lot of options, especially if your implementation looks anything like this:

class Computer
  SICK_BURN = "Mess with the best, die like the rest"

  def hack_computer(target)
    uri = URI.parse("https://example.com#{target}")
    req = Net::HTTP::Post.new(uri)

    Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
      http.request(req) do |res|
        if Net::HTTPSuccess === res
          return SICK_BURN
        else
          raise CrashOverrideError
        end
      end
    end
  end
end

This case is so simple that it may strain credulity, but instead of trying to introduce a network-layer mock to your application-layer concern, you might consider writing a wrapper around the Net::HTTP API that you don't control and can't change that your application can invoke wherever it needs to make a network request. Then, instead of your test needing to mock out the entire networking stack, it just needs to mock out the wrapper instead.

Here's what the refactored subject could look like if we extracted its usage of Net::HTTP into an Http wrapper that we own:

class Computer
  SICK_BURN = "Mess with the best, die like the rest"

  def initialize
    @http = Http.new
  end

  def hack_computer(target)
    if @http.post("https://example.com#{target}").success?
      return SICK_BURN
    else
      raise CrashOverrideError
    end
  end
end

class Http
  Result = Data.define(:json, :success?)

  def post(url)
    uri = URI.parse(url)
    req = Net::HTTP::Post.new(uri)

    Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
      http.request(req) do |res|
        return Result.new(
          data: JSON.parse(res.body),
          success?: Net::HTTPSuccess === res
        )
      end
    end
  end
end

Now, the test will be much easier to implement, because instead of trying to figure out how to mock out all of HTTP, we're simply mocking out an object that quacks just like any other object—leaving the fact that it crosses a spooky network boundary as a mere implementation detail that Computer's test doesn't need to worry about.

Now, a complete test could fully cover the subject with minimally-invasive mocking:

def initialize
  @http = Mocktail.of_next(Http)

  @subject = Computer.new
end

def test_success
  stubs { Http.post("/school_sprinklers") }.with { Computer::Result.new(data: nil, success?: true) }

  result = @subject.hack_computer("/school_sprinklers")

  assert_equal "Mess with the best, die like the rest", result
end

def test_failure
  stubs { Http.post("/the_gibson") }.with { Computer::Result.new(data: nil, success?: false) }

  assert_raises(CrashOverrideError) do
    @subject.hack_computer("/the_gibson")
  end
end

Anyway, that's why you probably don't want to fake out an HTTP interaction directly with Mocktail and should instead look to a purpose-built gem like webmock or else consider alternative designs that make the code in question easier to test.

If you've heard enough, you can go back and consider non-TDD use cases for Mocktail.

Or if you're finally ready to walk the golden path, you can revisit Mocktail as a TDD tool.