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