Elixir Mock 测试
在 Elixir 测试中,Mock 是虚拟化外部依赖的常用手段。本文将以心知天气 API 为例,介绍如何用 Hammox 进行 Mock 测试。
项目准备
环境要求
- Elixir 1.18.2
- Erlang 27.2.4
- Phoenix 1.7.19
# 验证版本
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
初始化项目
mix phx.new elixir_mock_test_demo \
--no-html \ # 禁用 HTML 模板
--no-assets \ # 禁用静态资源
--no-ecto \ # 禁用数据库
--no-mailer \ # 禁用邮件
--no-live \ # 禁用 LiveView
--no-dashboard # 禁用监控面板
实现天气查询功能
添加依赖
# mix.exs
defp deps do
[
{:req, "~> 0.5.8"}, # HTTP 客户端
{:dotenvy, "~> 1.0.1"}, # 环境变量管理
{:goal, "~> 1.2.0"} # 参数校验
]
end
环境配置
# 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
准备环境变量:
cp .env.example .env
Weather 模块
# 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
路由配置:
# lib/elixir_mock_test_demo_web/router.ex
get "/api/weather/:city", WeatherController, :show
手动测试
mix phx.server
curl http://localhost:4000/api/weather/beijing
# {"code":"9","temperature":"2","text":"阴"}
Mock 测试实现
添加 Hammox 依赖
# mix.exs
defp deps do
[
{:hammox, "~> 0.6.0", only: :test}
]
end
创建 Mock 模块
# 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
依赖注入改造
# config/config.exs
config :elixir_mock_test_demo, weather_service: ElixirMockTestDemo.Weather
# config/test.exs
config :elixir_mock_test_demo, weather_service: ElixirMockTestDemo.WeatherMock
修改 Controller:
# lib/elixir_mock_test_demo_web/controllers/weather_controller.ex
@weather_service Application.compile_env!(:elixir_mock_test_demo, :weather_service)
# 使用
{:ok, weather} <- @weather_service.get_forecast(params.city)
修改 Weather 模块,添加 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 ...
编写测试用例
# 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" => "阴"}
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
运行测试
mix test
第二个测试会失败,因为所有错误都返回了 :api_error。需要更新代码:
# 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"}})
再次运行测试即可通过。
注意:使用 Hammox 时,无法在编辑器中直接跳转到 mock 函数的定义。
完整代码仓库:elixir_mock_test_demo