Mock Testing in Elixir
In Elixir testing, mocking is a common approach for virtualizing external dependencies. This article uses the Seniverse weather API as an example to demonstrate how to perform mock testing with Hammox.
Project Setup
Requirements
- Elixir 1.18.2
- Erlang 27.2.4
- Phoenix 1.7.19
# Verify versions
elixir --version # 1.18.2
erl -eval '{ok, Version} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), "OTP_VERSION"])), io:fwrite(Version), halt().' -noshell # 27.2.4
Initialize Project
mix phx.new elixir_mock_test_demo \
--no-html \
--no-assets \
--no-ecto \
--no-mailer \
--no-live \
--no-dashboard
Implementing Weather Query
Add Dependencies
# mix.exs
defp deps do
[
{:req, "~> 0.5.8"}, # HTTP client
{:dotenvy, "~> 1.0.1"}, # Environment variable management
{:goal, "~> 1.2.0"} # Parameter validation
]
end
Environment Configuration
# config/runtime.exs
if config_env() not in [:test] do
import Dotenvy
env_dir_prefix = System.get_env("RELEASE_ROOT") || Path.expand("./")
source!([Path.absname(".env", env_dir_prefix), System.get_env()])
config :elixir_mock_test_demo, :seniverse,
public_key: env!("SENIVERSE_PUBLIC_KEY", :string!),
private_key: env!("SENIVERSE_PRIVATE_KEY", :string!)
end
Prepare the environment variables:
cp .env.example .env
Weather Module
# lib/elixir_mock_test_demo/weather.ex
defmodule ElixirMockTestDemo.Weather do
require Logger
@weather_uri "https://api.seniverse.com/v3/weather/now.json"
@ttl 300
@spec get_forecast(String.t()) :: {:ok, map()} | {:error, :api_error}
def get_forecast(city) do
timestamp = DateTime.utc_now() |> DateTime.to_unix()
query = %{ts: timestamp, ttl: @ttl, uid: public_key(), sig: create_sig(), location: city}
case Req.post(@weather_uri, params: query) do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body["results"] |> hd() |> then(& &1["now"])}
error ->
Logger.error("get forecast error: #{inspect(error)}")
{:error, :api_error}
end
end
defp create_sig do
timestamp = DateTime.utc_now() |> DateTime.to_unix()
:hmac
|> :crypto.mac(:sha, private_key(), "ts=#{timestamp}&ttl=#{@ttl}&uid=#{public_key()}")
|> Base.encode64()
end
defp public_key, do: config() |> Keyword.fetch!(:public_key)
defp private_key, do: config() |> Keyword.fetch!(:private_key)
defp config, do: Application.fetch_env!(:elixir_mock_test_demo, :seniverse)
end
Controller
# lib/elixir_mock_test_demo_web/controllers/weather_controller.ex
defmodule ElixirMockTestDemoWeb.WeatherController do
use ElixirMockTestDemoWeb, :controller
use Goal
defparams :show do
required :city, :string
end
def show(conn, unsafe_params) do
with {:ok, params} <- validate(:show, unsafe_params),
{:ok, weather} <- ElixirMockTestDemo.Weather.get_forecast(params.city) do
json(conn, weather)
else
{:error, :api_error} ->
conn
|> put_status(:internal_server_error)
|> json(%{errors: %{detail: "Internal Server Error"}})
{:error, %Ecto.Changeset{} = changeset} ->
errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
conn
|> put_status(:bad_request)
|> json(%{errors: errors})
end
end
end
Route configuration:
# lib/elixir_mock_test_demo_web/router.ex
get "/api/weather/:city", WeatherController, :show
Manual Test
mix phx.server
curl http://localhost:4000/api/weather/beijing
# {"code":"9","temperature":"2","text":"Overcast"}
Mock Test Implementation
Add Hammox Dependency
# mix.exs
defp deps do
[
{:hammox, "~> 0.6.0", only: :test}
]
end
Create Mock Module
# test/support/mocks.ex
Hammox.defmock(ElixirMockTestDemo.WeatherMock, for: ElixirMockTestDemo.Weather)
# test/support/mock_conn.ex
defmodule ElixirMockTestDemoWeb.MockCase do
use ExUnit.CaseTemplate
using do
quote do
import Hammox
setup :verify_on_exit!
end
end
end
Dependency Injection Refactoring
# config/config.exs
config :elixir_mock_test_demo, weather_service: ElixirMockTestDemo.Weather
# config/test.exs
config :elixir_mock_test_demo, weather_service: ElixirMockTestDemo.WeatherMock
Modify the Controller:
# lib/elixir_mock_test_demo_web/controllers/weather_controller.ex
@weather_service Application.compile_env!(:elixir_mock_test_demo, :weather_service)
# Usage
{:ok, weather} <- @weather_service.get_forecast(params.city)
Modify the Weather module to add behaviour:
# lib/elixir_mock_test_demo/weather.ex
@callback get_forecast(city :: String.t()) :: {:ok, map()} | {:error, :api_error}
@behaviour ElixirMockTestDemo.Weather
@impl ElixirMockTestDemo.Weather
@spec get_forecast(String.t()) :: {:ok, map()} | {:error, :api_error}
def get_forecast(city) do ...
Write Test Cases
# test/elixir_mock_test_demo_web/controllers/weather_controller_test.exs
defmodule ElixirMockTestDemoWeb.WeatherControllerTest do
use ElixirMockTestDemoWeb.ConnCase, async: true
use ElixirMockTestDemoWeb.MockCase
alias ElixirMockTestDemo.WeatherMock
describe "get weather" do
test "success", %{conn: conn} do
response = %{"code" => "9", "temperature" => "13", "text" => "Overcast"}
WeatherMock
|> expect(:get_forecast, fn "beijing" -> {:ok, response} end)
conn = get(conn, ~p"/api/weather/beijing")
assert ^response = json_response(conn, 200)
end
test "city not found", %{conn: conn} do
WeatherMock
|> expect(:get_forecast, fn "beijing1" -> {:error, :city_not_found} end)
conn = get(conn, ~p"/api/weather/beijing1")
assert json_response(conn, 404)
end
end
end
Run Tests
mix test
The second test will fail because all errors return :api_error. Update the code:
# lib/elixir_mock_test_demo/weather.ex
{:ok, %Req.Response{status: 404}} -> {:error, :city_not_found}
# lib/elixir_mock_test_demo_web/controllers/weather_controller.ex
{:error, :city_not_found} ->
conn
|> put_status(:not_found)
|> json(%{errors: %{detail: "City not found"}})
Run the tests again and they should pass.
Note: When using Hammox, you cannot directly jump to mock function definitions in the editor.
Complete source code: elixir_mock_test_demo