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.
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.