Testing Third Party Integrations

One thing that is always hard, regardless of what language you’re working in, is testing integrations with third party services. I mean, how are you going to test something like uploading files to S3 without actually uploading the file to S3?! The answer to that is usually “mocking,” but then there comes the question of how exactly one does that. Well, today I’m going to show how I test these sorts of things in code I work on. But before we get to the actual mocking and testing, there’s one thing we need to do first!

Wrap your dependencies

I’d say 99.99% of modern software uses open source dependencies to solve lots of problems for them. The thing with dependencies, though, is that they change. You probably don’t have any control over them, and you might need to replace them, or make significant changes to how your application integrates with these dependencies. And so, this is why I always recommend that folks “wrap” their dependencies. What does this mean? Well, basically it means that instead of this:

defmodule MyApp.Users do
  alias ExAws.{Config, S3}

  def save_avatar(to_bucket, to_path, file_path) do
    config = Config.new(:s3, Keyword.put(params, :json_codec, Jason))

    to_bucket
    |> S3.put_object(to_path, File.read!(file_path), opts)
    |> ExAws.request(config)
    |> case do
      {:ok, %{status_code: 200, body: _}} -> {:ok, to_path}
      error -> {:error, error}
    end
  end
end

where our application code is using our dependency directly, you would do this:

defmodule MyApp.Aws.S3 do
  alias ExAws.{Config, S3}

  def put_object(to_bucket, to_path, local_file, opts \\ []) do
    to_bucket
    |> S3.put_object(to_path, File.read!(local_file), opts)
    |> ExAws.request(config())
    |> case do
      {:ok, %{status_code: 200, body: _}} -> {:ok, to_path}
      error -> {:error, error}
    end
  end

  def config(params \\ []) do
    Config.new(:s3, Keyword.put(params, :json_codec, Jason))
  end
end

defmodule MyApp.Users do
  alias MyApp.Aws.S3

  def save_avatar(to_bucket, to_path, file_path) do
    S3.put_object(to_bucket, to_path, file_path)
  end
end

Now we have one function that we own, that wraps the behavior in the code that we don’t own. This gives us two advantages:

  • If we ever need to change the dependency that we’re using, or if there are breaking API changes we need to deal with, we’ve got a single place to deal with that change as opposed to many places throughout our application, and
  • It gives us a nice place to test things!

Testing our wrapper

Right, so we’ve got this one function that handles the integration with our library, and that library itself integrates with a third party service (in this case, AWS S3). So, how do we test this put_object/4 function? Well, we’ve got two levels at which we can do our mocking - at the library level, or at the HTTP client level.

By this I mean that we can mock out something like ExAws.request/2, ensuring that the right arguments are passed to that function. In theory, if ExAws is well tested and we trust that it’s working correctly, then we can be confident that our integration is going to work as long as we’ve tested that we’re passing the right arguments to that library. But I don’t love this idea, especially since there is still another option.

I much prefer to mock at the HTTP client level in this case, because that allows us to be even more confident, testing the functioning of the dependency all the way down to the HTTP client. And, yes, we’re still at this point assuming that the HTTP client works as intended and that given the correct arguments it’s going to do what we want. But, while still imperfect, it’s a really nice sweet spot between a good level of coverage and a reliable, reasonably easy test to set up and reason about.

So, this is how I would test that function in our wrapper:

# in config/text.exs
config :ex_aws,
  http_client: MyApp.ExAws.HttpClientMock

# in test/test_helper.exs
Mox.defmock(MyApp.ExAws.HttpClientMock, for: ExAws.Request.HttpClient)

# in tests/my_app/aws/s3_test.exs
defmodule MyApp.Aws.S3Test do
  use ExUnit.Case, async: true

  alias MyApp.ExAws.HttpClientMock

  describe "put_object/3" do
    test "makes the correct HTTP call to AWS" do
      Mox.expect(HttpClientMock, :request, fn method, url, body, headers, options ->
        content_length = byte_size(body)

        assert method == :put
        assert url == "https://s3.amazonaws.com/destination/file.jpg"
        assert options == []
        assert content_length > 0

        assert [
                {"Authorization", _},
                {"host", "s3.amazonaws.com"},
                {"x-amz-date", _},
                {"content-length", ^content_length},
                {"x-amz-content-sha256", _}
              ] = headers

        {:ok, %{status_code: 200, body: :ok}}
      end)

      assert {:ok, "file.jpg"} = S3.put_object("destination", "file.jpg", "local/file.jpg")

      Mox.verify!()
    end
  end
end

It’s not perfect, but I think it’s good, and this style of test has served me well throughout the years. Of course, since this is the single place that we’re integrating with this rather important thing, we’ll want to test it really thoroughly here. Like, several tests for success cases, and tests for every error case we can think of (within reason, of course).

Testing our application

So now that we have a function that does what we want when we call it, and that includes integrating with a third party service, what now? Well, we’ve got to make sure the rest of our application that uses this functionality is also well tested. But, how do we test it? Do we test just like above, or in some other way?

Well, you certainly could test at the HTTP client level every time you’re mocking out a call to S3, but I personally don’t do that. I’m of the mind that - since I own and wrote that wrapper and the tests for it myself, and I’ve probably even seen it work in production - that it will be ok to mock at that level instead of at the HTTP client level. What does this look like? Kind of like this!

defmodule MyApp.Users do
  alias MyApp.Aws.S3

  def save_avatar(to_bucket, to_path, file_path, s3_module \\ S3) do
    s3_module.put_object(to_bucket, to_path, file_path)
  end
end

defmodule MyApp.UsersTest do
  use ExUnit.Case, async: true

  alias MyApp.Users

  defmodule FakeS3 do
    def put_object(to_bucket, to_path, file_path) do
      send(self(), {:put_object, to_bucket, to_path, file_path})
    end
  end

  describe "save_avatar/4" do
    test "makes the right call to our s3_module" do
      assert {:ok, "file.jpg"} = Users.save_avatar("destination", "file.jpg", "local/file.jpg", FakeS3)
      assert_receive {:put_object, "destination", "file.jpg", "local/file.jpg"}
    end
  end
end

And that’s pretty much it! This idea of passing in a module as an argument to a function is a thing I’m a big fan of, and the other idea of using send to test side effects (in this case, HTTP calls) is another thing I’m a big fan of. I use them both a lot, and it’s a pattern that I recommend to just about everybody.

So, that’s it! Like all things that relate to third party dependency integration, you’re really going to be testing this stuff in production. These tests give us a pretty high level of confidence that things are working well, but nothing beats actually seeing the code work in production, so you’ll always want to have some way of observing things in production for sure.