Mocking HTTP API tests with Phoenix and Elixir

May 02, 2017

Mocking HTTP API tests with Phoenix and Elixir

Recently, I needed to write a test based on a return value from calling the YouTube API. Being new to Elixir and Phoenix and coming from Ruby on Rails, I surmised I had a few options.

Elixir basics

Option 1: Mock the test

I could mock out a test. This would save a lot of time, and provided they are done properly with an explicit contract as pointed out in this post by José Valim, they can be a good way to go.

Option 2: Real-time testing

I could try and hit the HTTP endpoint every time. Or I could try and use a library similar to the vcr gem in the Ruby world. There’s one called exvcr apparently.

Choose mocking – Option 1

In the end, I chose to mock the test. I chose it so that I wouldn’t have to add another dependency (exvcr plugin) and I would pay the cost of doing real-time testing.

Step 1: Setup a mock library

The first step is to create a mocking library that you are going to swap out in the actual code that relies on your HTTP library calling code.

As an example, let’s say you have a module called VideoDataFetcher that uses another module called YouTubeFetcher which pulls data via the YouTube API.

I would create another module called YouTubeFetcher.Mock that would return mocked out results from the YouTube API. This would be used in the VideoDataFetcher class.

To give you a visual idea, here’s some sample code:

The YouTubeFetcher is responsible for making calls to the YouTube API.

defmodule YouTubeFetcher do
  import ResponseParser
  use HTTPotion.Base

  def get_request(base_url_type, query_opts) do
    HTTPotion.get(base_url(base_url_type), query: Map.merge(query_opts, %{key: YouTubeFetcher.yt_fetcher_key}))
  end

  def base_url(url) do
    "https://www.googleapis.com/youtube/v3/" <> url
  end

  def yt_fetcher_key, do: Application.get_env(:youtuber_application, :yt_fetcher_id)

The YouTubeFetcher.Mock is responsible for returning “mocked” HTTP responses.

# Mock the Youtube responses in test so we don't have to make an HTTP call
defmodule YouTubeFetcher.Mock do
  def get_request("search", query_opts=%{channelId: "FAKE_YOUTUBE_CHANNEL_ID_no_page_token", part: "snippet", maxResults: "50", type: "video", order: "date"}) do
  %HTTPotion.Response{body: "{\n \"kind\": \"youtube#searchListResponse\",\n \"etag\": \"\\\"sKKe\\\"\",\n \"regionCode\": \"US\",\n \"pageInfo\": {\n  \"totalResults\": 1,\n  \"resultsPerPage\": 1\n },\n \"items\": [\n  {\n   \"kind\": \"youtube#searchResult\",\n   \"etag\": \"\\\"sZ5ym0/-SO5eWti4Tq\\\"\",\n   \"id\": {\n    \"kind\": \"youtube#video\",\n    \"videoId\": \"vaGoDZN8XNM\"\n   },\n   \"snippet\": {\n    \"publishedAt\": \"2016-09-15T02:00:00.000Z\",\n    \"channelId\": \"FAKE_YOUTUBE_CHANNEL_ID_no_page_token\",\n    \"title\": \"Ninja Movie 2016\",\n    \"description\": \"Watch ninjas run. » Subscribe for More: http://bit.ly/Ninja » Watch Full Episodes Free: ...\",\n    \"thumbnails\": {\n     \"default\": {\n      \"url\": \"https://i.ytimg.com/vi/vaGoDZN/default.jpg\",\n      \"width\": 120,\n      \"height\": 90\n     },\n     \"medium\": {\n      \"url\": \"https://i.ytimg.com/vi/vaGoDZN8XNM/mqdefault.jpg\",\n      \"width\": 320,\n      \"height\": 180\n     },\n     \"high\": {\n      \"url\": \"https://i.ytimg.com/vi/vaGoDZN8XNM/hqdefault.jpg\",\n      \"width\": 480,\n      \"height\": 360\n     }\n    },\n    \"channelTitle\": \"Ninja Go\",\n    \"liveBroadcastContent\": \"none\"\n   }\n  },\n  {\n   \"kind\": \"youtube#searchResult\",\n   \"etag\": \"\\\"ZZJym08\\\"\",\n   \"id\": {\n    \"kind\": \"youtube#video\",\n    \"videoId\": \"AjMpzTOGywA\"\n   },\n ]\n}\n",
 headers: %HTTPotion.Headers{hdrs: %{"alt-svc" => "quic=\":443\"; ma=2592000; v=\"36,35,34\"",
    "cache-control" => "private, max-age=120, must-revalidate, no-transform",
    "content-length" => "52475",
    "content-type" => "application/json; charset=UTF-8",
    "date" => "Fri, 11 Nov 2016 21:02:25 GMT",
    "etag" => "\"sZ5p5E\"",
    "expires" => "Fri, 11 Nov 2016 21:02:25 GMT", "server" => "GSE",
    "vary" => ["X-Origin", "Origin"], "x-content-type-options" => "nosniff",
    "x-frame-options" => "SAMEORIGIN", "x-xss-protection" => "1; mode=block"}},
 status_code: 200}
  end
end

Step 2: Setup the corresponding module so it can use the mock library

The VideoFetcher makes use of the YouTubeFetcher class. Some function definitions have been omitted for brevity.

defmodule VideoFetcher do
  # below is where we setup the VideoFetcher module to use whichever library
  @yt_fetcher Application.get_env(:competitive_networks, :yt_fetcher)

  def search_videos(yt_channel_id, opts = %{part: "snippet", maxResults: "50", type: "video", order: "date", publishedAfter: published_after}, video_data) do
    raw_json = @yt_api.get_request("search", Map.merge(%{channelId: yt_channel_id}, opts))
                |> decode_json

    search_videos_page_token(yt_channel_id, Map.merge(opts, %{pageToken: raw_json["nextPageToken"]}), video_data ++ [raw_json])
  end

  def search_videos_page_token(yt_channel_id, opts = %{part: "snippet", maxResults: "50", type: "video", order: "date", pageToken: nil, publishedAfter: published_after}, video_data) do
    video_data
  end

  def search_videos_page_token(yt_channel_id, opts = %{part: "snippet", maxResults: "50", type: "video", order: "date", pageToken: token, publishedAfter: published_after}, video_data) do
    raw_json = @yt_api.get_request("search", Map.merge(%{channelId: yt_channel_id}, opts))
                |> decode_json

    search_videos_page_token(yt_channel_id, Map.merge(opts, %{pageToken: raw_json["nextPageToken"]}), video_data ++ [raw_json])
  end
end

Step 3: Configure environment variables in the config directory for each environment

The next step is to configure the YouTubeFetcher library for use with development and production and to configure the YouTubeFetcher.Mock for use with test environment.

config/test.exs

# Config API library for use on per environment basis
config :youtuber_application, :yt_fetcher, YouTubeFetcher.Mock

config/dev.exs

# Config API library for use on per environment basis
config :youtuber_application, :yt_fetcher, YouTubeFetcher

Step 4: Write your tests

Next populate the test directory and run mix test. Because you already set the configuration in config/test.exs, the test suite knows to use the YouTubeFetcher.mock library.

defmodule VideoFetcherTest do
  use ExUnit.Case

  setup context do
    context_dict = Map.new
    context_dict = Map.put(context_dict, :test_1, %{channelId: "FAKE_YOUTUBE_CHANNEL_ID_no_page_token", query_options: %{part: "snippet", maxResults: "50", type: "video", order: "date"}})
  end

  test "1: #search_videos only returns videos published in this week w/o pageToken", context do
    assert VideoFetcher.search_videos(context[:test_1][:channelId], context[:test_1][:query_options]) == [%{"etag" => "\"sZ5p5Mo8dPpfIzLYQBF8QIQJym0/Zw3YBLupPtZvIDuoyrfueGs6bKE\"",
  "items" => [%{channel_uuid: "FAKE_YOUTUBE_CHANNEL_ID_no_page_token",
     video_uuid: "AjMpzTOGywA"}],
     "kind" => "youtube#searchListResponse",
  "pageInfo" => %{"resultsPerPage" => 1, "totalResults" => 1},
  "regionCode" => "US"
    }]
  end

Summary

If you want to mock an HTTP API response for testing in Elixir, it’s fairly straightforward to do so. Just follow Steps 1-4 above.


Profile picture

Written by Bruce Park who lives and works in the USA building useful things. He is sometimes around on Twitter.