Testing a controller that makes outside api calls

Overview

How to test my JSON API controller when a module inside of it relies on making a 3rd party api call.

Use the example from Bypass

I found an example of using Bypass. From the github repo they show an example of unit testing a Twitter client.

Controller Test

Take that example from the github repo of a TwitterClient and let’s create a controller and nest the TwitterClient inside of it. So that every time you visit the endpoint it sends out a tweet. Anytime I visit /api/tweet my controller will TwitterClient.post_tweet/1 and return the tweet we get back from twitter and render some json.

Controller

defmodule MyAppWeb.Api.TweetController do
  use MyAppWeb, :controller
  action_fallback MyAppWeb.Api.FallbackController

  def index(conn, params) do
    with {:ok, result} <- TwitterClient.post_tweet("Elixir is awesome!") do
      render(conn, "show.json",  tweet: result)
    end
  end
end

TestController

defmodule MyAppWeb.Api.TweetControllerTest do
  use MyAppWeb.ConnCase

  describe "GET /tweet" do
    test "success, it sends a tweet", %{conn: conn} do
      conn = get(conn, "/tweet")
      assert json_response(conn, 200) == %{tweet: "Elixir is awesome"}
    end
  end
  
end

If I run this test "GET /tweet" the module TwitterClient is going to hit the live twitter website.

Question

How do I test this controller, but prevent the TwitterClient which I don’t own from actually making the live call to Twitter?

Within my test controller, am I supposed to intercept external HTTP request?

Do I need to read up on Mox?

Any direction, material articles would be appreciated.

1 Like

How do I test this controller, but prevent the TwitterClient which I don’t own from actually making the live call to Twitter?

I think you have essentially 2 options, and it all boils down to configuration:

  1. Make your TwitterClient configurable, so that in the test environment it will be started/configured with an endpoint URL pointing to the local Bypass endpoint.

  2. Use Mox and make sure, again via configuration, that your TweetController uses the mocked version of TwitterClient in testing.

If you choose option 1, you will be testing both the controller and the client when testing the controller, and your test will be more “integration-like”. If you choose option 2, your test will be more isolated (you’ll be testing only the controller).

5 Likes

Bear in mind you can only use Mox if your 3rd party TwitterClient defines a @behaviour. Otherwise you’ll need to investigate other mocking libraries, or mock ad hoc in your tests. Here’s a useful article on the Mox approach: Elixir Test Mocking with Mox. Building an api client mock and… | by Sophie DeBenedetto | Flatiron Labs | Medium

3 Likes

crispinb. Great article. This is helpful. Although I am overwhelmed reading docs on bypass and mox.

1 Like

trisolaran
With option 1 I can’t figure from my controller test, how I would pass down the Bypass endpoint when my controller test is running.

The configuration is what I’m struggling with. With unit tests it’s easy because you can pass it right in. On a controller test I don’t see how I can pass in the Bypass endpoint from set up.

I think I’m going to go with option 2 with Mox and see how far it takes me.

1 Like

I think that using Mox (or some other mocking library) makes sense in this case because, as you said, you don’t own the TwitterClient, and so it’s not your job to test its implementation.

But to answer your question about the configuration: looking at the example on GitHub that you shared, I see that the TwitterClient has to be started with a url option. Assuming that you’re starting it in your application’s supervision tree, you can do something like this:

in your lib/myapp/application.ex:

def start(_type, _args) do
    children = [
      {TwitterClient, Application.get_env(:myapp, TwitterClient)}
      ... other apps here
    ]

    opts = [strategy: :one_for_one, name: DataIngestion.Supervisor]
    Supervisor.start_link(children, opts)
  end

in your config/config.exs:

config :myapp, TwitterClient, url: REAL_TWITTER_API_ENDPOINT

in your config/test.exs:

config :myapp, TwitterClient, url: BYPASS_ENDPOINT

Where BYPASS_ENDPOINT is the local endpoint your bypass instance will be listening to. Then you can start Bypass and set up the required expectations before testing the controller.

3 Likes

You can simply wrap the TwitterClient module in a module of your own that defines a behavior, you don’t need the library to do so.

7 Likes

True. Tastes vary on this, but while I find it reasonable (advisable!) to refactor towards testability, I wouldn’t myself choose to refactor just to use a specific mocking library. Of course there might be good reasons to do so in particular cases.

2 Likes

I like this idea of configuration through config.exs and test.exs.

Revising the example

Based on your feedback, the new set up for the controller would use the @endpoint attribute and it would return a url based on the environment that it’s in.

controller(revised)

defmodule MyAppWeb.Api.TweetController do
  use MyAppWeb, :controller
  action_fallback MyAppWeb.Api.FallbackController

  @endpoint Application.get_env(:myapp, :url)

  def index(conn, params) do
    with {:ok, result} <- TwitterClient(@endpoint).post_tweet("Elixir is awesome!") do
      render(conn, "show.json",  tweet: result)
    end
  end
end

How do I test this controller in my test controller?

test controller

defmodule MyAppWeb.Api.TweetControllerTest do
  use MyAppWeb.ConnCase

# I just need to put this here and it will work??
 setup do
    bypass = Bypass.open()
    {:ok, bypass: bypass}
  end

  describe "GET /tweet" do
    test "success, it sends a tweet", %{conn: conn, bypass: bypass} do
      # Is `bypass` being passed here the same bypass
     # that is in my config :myapp, TwitterClient, url: BYPASS_ENDPOINT
       Bypass.expect(bypass, fn conn ->
            conn
            |> Plug.Conn.put_resp_header("content-type", "application/json")
            |> Plug.Conn.resp(200, %{tweet: "Elixir is awesome"})
      end)
      # controller
      conn = get(conn, "/tweet")
      assert json_response(conn, 200) == %{tweet: "Elixir is awesome"}
    end
  end
  
end
1 Like

To alleviate the risk of falling into the trap of wishful thinking that mocking incurs, just use curl (or Postman, Insomnia etc.) to download responses from the actual live API, save them on your machine and then make text fixtures out of them. Something like the following (or you can save them in CSV / XML / JSON and File.read! them inside the mocking functions):

test "whatever" do
  # You should obviously configure a special test implementation that uses `Mox`.
  # This is well-documented elsewhere.
  expect(ThirdPartyApi.Mocked, :get_exchange_rates, fn %{from: "USD"} ->
    # put stuff in here that you got from the actual live / production API.
  end

  assert your_function_calling_the_3rd_party_api() == :desired_result
end

Using Mox is an advantage because it forces you to think of the contract of whatever you want to mock. @benwilson512 alludes to this by advising you to still contract your 3rd-party-consuming modules through using your own behaviour even if the original 3rd party library doesn’t offer one.

You’ll find that as you are Mox-enabling your tests (so to speak) you’ll dig up additional insights along the way. That’s valuable.

4 Likes

Excellent advice. This also encourages you to keep your mocks thin, and makes use of the API data easy to expand as needs dictate. Maybe this is just me, but I have a tendency to pluck the few values from the API return I think I need for my model, only to find later there’s gold there that I missed. With raw api returns available locally, everything’s always there to be mined later.

3 Likes

Yep. I’ve had 20MB JSON fixtures and just made utility functions to get pieces of them for smaller unit-testing purposes.

I went even further in one project: I made a special tag for certain tests (called it :exhaustive) and excluded it by default so we don’t overload our CI – but made it a policy to run the tests tagged with it once a week. They used the complete cached payloads from the live API, no matter how big they were (one was 215MB even; long live Zstandard level 19 compression and git lfs!).

A lot of teams grumble about this practice, for reasons they were never able to explain in a satisfactory manner to me, but I’ve uncovered a frightening amount of bugs in code just by using cached real data.

5 Likes

To Bypass or Mox
It can be overwhelming trying to understand the difference between whether I should use a Mox or a Bypass. And at the same time trying to understand behaviours. I got the weekend to further educate and explore. Not complaining, just a lot of concepts at once and they have subtle differences.

I like the idea of using a Behaviour (a contract), that I then implement in a module that then I can then mock in my tests.

I have download real json results that I can reuse as your suggestions. I keep them in my tests.

This community is awesome, appreciate everyones feedback and direction so far.

3 Likes

I sympathize, dude, but there simply are no shortcuts. At one point you have to roll up your sleeves and get your battle scars. Glad you are motivated to do it!

This community will be extremely helpful and supportive if you show that you’ve done your homework – or are willing to do it. So keep at it, you’ll be a master in no time!

3 Likes

There is also patch

It’s not specifically related to http but it could allow you to override (patch) the function that make the api call :wink:

Really nice lib btw

2 Likes

This is how I decide. If the API module that talks to the external API (this would be TwiterClient in your example) is part of my codebase, I write tests for it and simulate the interactions with the external API using Bypass. If the module is part of a library, then it’s (hopefully) already tested and so I don’t write any tests for it. When testing a consumer of the API module, I mock the API module.

This seems pretty straightforward to me, but I’d be interested to hear what other people on this thread have to say, maybe they have a different opinion.

When writing tests using Bypass, I can confirm that @dimitarvp’s suggestions of dumping response from the real API to generate fixtures is indeed a very good one! I’m glad that others are also doing it :slight_smile:

1 Like

If you can keep this in the back of your mind to check out at some stage (ie. not to add to the immediate overwhelm!), bear in mind there’s at least one other approach to creating a module ‘api’, using protocols. There’s a bit of back and forth about the pros and cons, and how they relate to mocking, here: Mox and Protocols? - #13 by svarlet.

How do you define ‘talks to the API’ here? If for example your module uses HTTPoison (or similar), do you consider this all under your control, so use Bypass, or would you mock out HTTPoison (or perhaps your HTTPoison.Base callback module)?

Background: I find myself uncertain in concrete cases, similarly to @neuone, re which approach to take (as I sometimes do with behaviours vs protocols). I tend to start by analogy with other languages I’ve used, but then the analogies don’t seem too convincing. I probably need to read more good Elixir code.

1 Like

If my module uses HTTPoison or Tesla, then I consider it to be the module talking to the API. HTTPoison and Tesla are convenience libraries that my module uses to send HTTP requests. However it’s my module that needs to decide which HTTP requests to send and therefore the one operating at the HTTP level and “talking to the API”.

As you mention, in this case, mocking HTTPoison or Tesla is an option. However, I don’t do that anymore and use Bypass instead. What convinced me is this article: Testing External Web Requests in Elixir? Roll Your Own Mock Server | by Sophie DeBenedetto | Flatiron Labs | Medium, from the same author of the post you previously shared. I recommend reading it and also José’s article about mocks linked from it (Mocks and explicit contracts « Plataformatec Blog).

2 Likes

Thanks, very useful links. Echoing the old ‘how can I know what I think until I see what I write?’ cliche, I’m not entirely sure about the Bypass approach, not having used it yet. But I just happen to be working on an api client, currently using Mox, so I’ll give this approach a try and see how it looks.

1 Like