再利用性の高い Tesla.Mock の使い方

# はじめに
この記事は Elixir Advent Calendar 2024 シリーズ 1 の 22 日目です。

https://qiita.com/advent-calendar/2024/elixir

[@koyo-miyamura](https://x.com/KoyoMiyamura) です！
最近は1プロダクトのテックリードになりまして悪戦苦闘している日々です。

https://moneyforward-dev.jp/entry/2024/12/08/192500

時間を見つけて技術活動に取り組んでおり、Elixir はその中の一種の清涼剤です。
本日は Bright の開発でも使用したテクニックについて紹介します。

https://bright-fun.org/

# Elixir 製 HTTP クライアント Tesla

Elixir には様々な HTTP クライアントがあります。
その中でも Tesla は Ruby でよく使われる [Faraday](https://github.com/lostisland/faraday) にインスパイアされたライブラリです。
プロダクションで実装する場合 Tesla を直接使うのではなく、対象 API への HTTP クライアントモジュールを作成することが多いと思いますが、Tesla はそのようなクライアントモジュールを作成することに向いています。

https://github.com/elixir-tesla/tesla

## Tesla.Mock

さて、HTTP クライアントを作る時に、必要になるのがリクエストのモックです。
プロダクション開発ではテストコードは基本的に必須です。しかしテストの度に本物の API を実行していてはセットアップも大変ですし、CI も不安定になります。
Tesla はモック機構も用意してくれており、以下にサンプルが書かれています。

https://hexdocs.pm/tesla/Tesla.Mock.html

```elixir
defmodule MyAppTest do
  use ExUnit.Case

  setup do
    Tesla.Mock.mock(fn
      %{method: :get} ->
        %Tesla.Env{status: 200, body: "hello"}
    end)

    :ok
  end

  test "list things" do
    assert {:ok, env} = MyApp.get("...")
    assert env.status == 200
    assert env.body == "hello"
  end
end
```

上記のように、簡単にモックができます！

# モック定義の再利用 ~ ZOHO の認証 API を例に ~

さて、実際に使用すると、以下のようなモックを「複数定義して、なおかつテストケースごとに切り替えたい」という需要が発生します。

```elixir
  setup do
    Tesla.Mock.mock(fn
      %{method: :get} ->
        %Tesla.Env{status: 200, body: "hello"}
    end)

    :ok
  end
  ```

実際の例を見てみましょう。

## ZOHO API の OAuth 2.0 実装

ZOHO という様々な業務アプリケーションを統合して提供しているソフトウェアがあります。API も提供しており、認証は以下のように OAuth 2.0 で行うことが出来ます。

https://www.zoho.com/accounts/protocol/oauth.html

## Elixir での実装例

今回はブラウザを介さない `Client Credentials Flow` で認証を行う方式で実装してみます。

```elixir
defmodule Myapp.Zoho.Auth do
  @moduledoc """
  Zoho の認証を行うモジュール
  """

  @doc """
  認証用のクライアントを生成する

  ## Examples

      iex> Myapp.Zoho.Auth.new()
      %Tesla.Client{}
  """
  def new do
    middleware = [
      {Tesla.Middleware.BaseUrl, "https://accounts.zoho.jp"},
      Tesla.Middleware.JSON
    ]

    Tesla.client(middleware)
  end

  @doc """
  oauth で認証を行う

  ## Examples

      iex> Myapp.Zoho.Auth.new() |> Myapp.Zoho.Auth.auth()
      {:ok, %Tesla.Env{body: %{"access_token" => "xxxxx", "expires_in" => 3600, "api_domain" => "https://www.zohoapis.jp"}}}

      iex> Myapp.Zoho.Auth.new() |> Myapp.Zoho.Auth.auth()
      {:ok, %Tesla.Env{body: %{"error" => "invalid_client"}}}

      iex> Myapp.Zoho.Auth.new() |> Myapp.Zoho.Auth.auth()
      {:error, :econnrefused}
  """
  def auth(client) do
    client
    |> Tesla.post("/oauth/v2/token", %{},
      query: [
        grant_type: "client_credentials",
        client_id: System.get_env("ZOHO_CLIENT_ID"),
        client_secret: System.get_env("ZOHO_CLIENT_SECRET"),
        scope: "ZohoCRM.modules.ALL",
        soid: System.get_env("ZOHO_CRM_ZSOID")
      ]
    )
  end
end
```

## Zoho.Auth モジュールのテスト

さて上記モジュールのテストを書いてみましょう。

```elixir
defmodule Myapp.Zoho.AuthTest do
  use ExUnit.Case, async: true

  alias Myapp.Zoho.Auth

  describe "new/1" do
    test "returns a Tesla client" do
      assert %Tesla.Client{} = Auth.new()
    end
  end

  describe "auth/1" do
    test "returns a Tesla.Env" do
      Tesla.Mock.mock(fn
        %{method: :post, url: "https://accounts.zoho.jp/oauth/v2/token"} ->
          %Tesla.Env{
            status: 200,
            body: %{
              "access_token" => "new_token",
              "expires_in" => 3600,
              "api_domain" => "https://www.zohoapis.jp"
            }
          }
      end)

      assert {:ok,
              %Tesla.Env{
                body: %{
                  "access_token" => "new_token",
                  "expires_in" => 3600,
                  "api_domain" => "https://www.zohoapis.jp"
                }
              }} = Auth.new() |> Auth.auth()
    end

    test "returns an error" do
      Tesla.Mock.mock(fn
        %{method: :post, url: "https://accounts.zoho.jp/oauth/v2/token"} ->
          %Tesla.Env{
            status: 200,
            body: %{"error" => "invalid_client"}
          }
      end)

      assert {:ok, %Tesla.Env{body: %{"error" => "invalid_client"}}} = Auth.new() |> Auth.auth()
    end
end
```

これでも動くのですが、ややモック定義が煩雑です。テストケースを書くたびに毎回モック定義を書かねばならないですし、再利用性も悪いです。また、チーム開発では同じようなモックでも細部が違ったりしてモックが氾濫してしまう恐れもあります。
これを防ぐために共通化していきましょう。

## 共通化

シンプルに共通化するなら `Tesla.Mock ...` の部分を共通モジュールに定義して関数化すればよいと思います。簡単なケースではそれで十分なことも多いでしょう。

今回はもう少し踏み込んだモジュール定義を紹介したいと思います。
まずテストについて考えてみると、モック部分は重要でなく、重要なのはテスト内容の方です。主役はモックではありません。

モック定義を test ブロックの中に書いてしまうと、主体ではないモック定義がテスト本体の近くに書かれるので、テストコード本体が若干読みにくくなってしまいます。できれば setup ブロックに定義したいところです。

しかし setup ブロックに定義すると、モックごとに describe ブロックを切り替えないといけなくなります。これも少し煩雑です。

### 共通モジュール + `@tag` の利用

これを解決するために以下のような共通モジュールを定義します。少しマクロを使用していますが、やってることは　`use` された時に `import` と `setup` を呼ぶように書き換えているだけです。

```elixir
defmodule Myapp.Zoho.MockSetup do
  @moduledoc """
  Zoho CRM API のモックをセットアップする

  use Myapp.Zoho.MockSetup

  @tag zoho_crm_mock: :auth_success
  test "xxx" do
    xxx
  end
  """

  use ExUnit.Callbacks

  defmacro __using__(_opts) do
    quote do
      import Myapp.Zoho.MockSetup
      setup :mock_zoho_api
    end
  end

  defp auth_success_response do
    %Tesla.Env{
      status: 200,
      body: %{
        "access_token" => "new_token",
        "expires_in" => 3600,
        "api_domain" => "https://www.zohoapis.jp"
      }
    }
  end

  def auth_failure_response do
    %Tesla.Env{
      status: 200,
      body: %{"error" => "invalid_client"}
    }
  end

  def mock_zoho_api(%{zoho_mock: :auth_success}) do
    Tesla.Mock.mock(fn
      %{method: :post, url: "https://accounts.zoho.jp/oauth/v2/token"} -> auth_success_response()
    end)
  end

  def mock_zoho_api(%{zoho_mock: :auth_failure}) do
    Tesla.Mock.mock(fn
      %{method: :post, url: "https://accounts.zoho.jp/oauth/v2/token"} -> auth_failure_response()
    end)
  end

  # @tag がない場合にエラーにならないよう定義
  def mock_zoho_api(%{}), do: :ok
end
```

ExUnit には `@tag` アノテーションで `setup` 時に引数（context）を与えることができます。
この仕組みを利用して、`@tag` に context としてモック名をキーにして渡すことで、複数のモック管理を実現しています。
また、この context を受け取る setup 関数はパターンマッチ可能なので、複雑な if 文なしで、シンプルかつ簡単にパターンを増やすことが出来ます。

https://hexdocs.pm/ex_unit/1.15.0/ExUnit.Case.html#module-tags

## 共通モックモジュールを使ったテストの書き換え

先ほどのテストを以下のように書き換えてみましょう。

```elixir
defmodule Myapp.Zoho.AuthTest do
  use ExUnit.Case, async: true
  use Myapp.Zoho.MockSetup # use 追加

  alias Myapp.Zoho.Auth

  describe "new/1" do
    test "returns a Tesla client" do
      assert %Tesla.Client{} = Auth.new()
    end
  end

  describe "auth/1" do
    # モック定義を @tag で指定
    @tag zoho_mock: :auth_success
    test "returns a Tesla.Env" do
      assert {:ok,
              %Tesla.Env{
                body: %{
                  "access_token" => "new_token",
                  "expires_in" => 3600,
                  "api_domain" => "https://www.zohoapis.jp"
                }
              }} = Auth.new() |> Auth.auth()
    end

    @tag zoho_mock: :auth_failure
    test "returns an error" do
      assert {:ok, %Tesla.Env{body: %{"error" => "invalid_client"}}} = Auth.new() |> Auth.auth()
    end
  end
end
```

とってもシンプルになりました！

モックケース増やしたい場合はシンプルにパターンマッチを増やすだけでよいですし、`@tag` をみれば何をモックしたいかが明確に分かります。
また別のテストモジュールで `use Myapp.Zoho.MockSetup` することで、他のテストでもモックを再利用することができますね。

## Appendix

  コードの全量は以下に公開されているので、より興味がある人はどうぞ。

  - [Mock 共通モジュール](https://github.com/bright-org/bright/blob/4cad965824f4047153aa14445c05bbae03a2b5ec/test/support/zoho/mock_setup.ex#L1-L322)
  - [Zoho の認証モジュールのテスト](https://github.com/bright-org/bright/blob/d3c4ddb34e4d75ab5437064a64a9c0fa221805aa/test/bright/zoho/auth_test.exs#L1-L41)


# まとめ
本記事では再利用性の高い `Tesla.Mock` の使い方を紹介しました。
Elixir ならではのテクニックを使うことで、簡単に共通化することができました！

ExUnit は Ruby の Rspec に比べると、`context` 階層が切れなかったり `shared_context` が使えないのでテストの構造化がしにくいと思われがちです（私もそう思っていた時期がありましたｗ）。
しかし `@tag` の機能や、テスト用の共通モジュールの実装、そしてパターンマッチを使うことで、工夫次第で Rspec のように柔軟なテストを記載することができます。
本記事のテクニックは、Elixir のテストの共通化全般に役立つ手法なので、ぜひ皆さんも採用してみて、事例をブログに書いてみてください！

それでは引き続き、Elixir Advent Calendar 2024 をお楽しみください。