Elixir Mock 测试
在编写测试的过程中总是有一些东西需要 mock 来虚拟化依赖对象,这里将要讲的是如何 mock 第三方 API。
这里我们使用一个免费的天气 API,心知天气的接口天气实况。 如何使用请参考其文档,准备好相应的 KEY:
- SENIVERSE_PRIVATE_KEY
- SENIVERSE_PUBLIC_KEY
项目准备
环境要求
- Elixir 1.18.2
- Erlang 27.2.4
- Phoenix 1.7.19
# 验证Elixir/Erlang版本
elixir --version # 应输出 1.18.4
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 # 禁用 Phoenix 监控面板
实现天气查询功能
依赖配置
添加第三方依赖
# File: mix.exs
defp deps do
[
...,
{:req, "~> 0.5.8"}, # HTTP 客户端
{:dotenvy, "~> 1.0.1"}, # 使用 .env 存储敏感信息
{:goal, "~> 1.2.0"} # 参数校验
]
环境配置
# File: 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()])
# API 密钥配置
config :elixir_mock_test_demo, :seniverse,
public_key: env!("SENIVERSE_PUBLIC_KEY", :string!),
private_key: env!("SENIVERSE_PRIVATE_KEY", :string!)
end
环境变量初始化命令 同时参考 .env.example 更新相关的 Key
cp .env.example .env # 复制模板文件
核心逻辑实现
Weather 模块
# File: 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(fn x -> x["now"] end)}
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
创建 Weather Controller
# File: 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
# 路由配置
# File: lib/elixir_mock_test_demo_web/router.ex
get "/weather/:city", WeatherController, :show
手动测试
# 启动服务
mix phx.server
# 测试请求
curl http://localhost:4000/api/weather/beijing | jq
{
"code": "9",
"temperature": "2",
"text": "阴"
}
Mock 测试实现
添加依赖
# File: mix.exs
defp deps do
[
...
{:hammox, "~> 0.6.0", only: :test},
]
创建 Mock 模块
# File: test/support/mocks.ex
Hammox.defmock(ElixirMockTestDemo.WeatherMock, for: ElixirMockTestDemo.Weather)
# File: 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
依赖注入改造
环境配置切换
# File: config/config.exs
config :elixir_mock_test_demo,
weather_service: ElixirMockTestDemo.Weather
# File: config/test.exs
config :elixir_mock_test_demo,
weather_service: ElixirMockTestDemo.WeatherMock
Controller 改造
# File: lib/elixir_mock_test_demo_web/controllers/weather_controller.ex
@weather_service Application.compile_env!(:elixir_mock_test_demo, :weather_service)
# {:ok, weather} <- ElixirMockTestDemo.Weather.get_forecast(params.city) do 更新为
{:ok, weather} <- @weather_service.get_forecast(params.city) do
Weather 模块改造
# File: lib/elixir_mock_test_demo/weather.ex
@callback get_forecast(city :: String.t()) :: {:ok, map()} | {:error, :api_error}
@behaviour ElixirMockTestDemo.Weather
# @spec 这一行更新为
@impl ElixirMockTestDemo.Weather
编写测试用例
正常流程测试
# File: 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
end
end
异常场景测试
# File: test/elixir_mock_test_demo_web/controllers/weather_controller_test.exs
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
运行测试
mix test
此时我们会发现,第二个测试无法通过。原因是我们把所有的异常情况都直接返回为 :api_error 了。 所以回到 weather 模块,添加找不到城市的匹配
# File: lib/elixir_mock_test_demo/weather.ex
{:ok, %Req.Response{status: 404}} ->
{:error, :city_not_found}
# File: 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"}})
# 再次运行测试通过
mix test
但这个 mock 让我觉得有些小问题,比如我无法在编辑器中直接跳转到相关函数的定义了。
最终代码的仓库地址:elixir_mock_test_demo