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