はじめに
この記事は Elixir Advent Calendar 2024 シリーズ 1 の 22 日目です。
@koyo-miyamura です!
最近は1プロダクトのテックリードになりまして悪戦苦闘している日々です。
時間を見つけて技術活動に取り組んでおり、Elixir はその中の一種の清涼剤です。
本日は Bright の開発でも使用したテクニックについて紹介します。
Elixir 製 HTTP クライアント Tesla
Elixir には様々な HTTP クライアントがあります。
その中でも Tesla は Ruby でよく使われる Faraday にインスパイアされたライブラリです。
プロダクションで実装する場合 Tesla を直接使うのではなく、対象 API への HTTP クライアントモジュールを作成することが多いと思いますが、Tesla はそのようなクライアントモジュールを作成することに向いています。
Tesla.Mock
さて、HTTP クライアントを作る時に、必要になるのがリクエストのモックです。
プロダクション開発ではテストコードは基本的に必須です。しかしテストの度に本物の API を実行していてはセットアップも大変ですし、CI も不安定になります。
Tesla はモック機構も用意してくれており、以下にサンプルが書かれています。
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 を例に ~
さて、実際に使用すると、以下のようなモックを「複数定義して、なおかつテストケースごとに切り替えたい」という需要が発生します。
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 で行うことが出来ます。
Elixir での実装例
今回はブラウザを介さない Client Credentials Flow
で認証を行う方式で実装してみます。
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 モジュールのテスト
さて上記モジュールのテストを書いてみましょう。
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
を呼ぶように書き換えているだけです。
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 文なしで、シンプルかつ簡単にパターンを増やすことが出来ます。
共通モックモジュールを使ったテストの書き換え
先ほどのテストを以下のように書き換えてみましょう。
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
コードの全量は以下に公開されているので、より興味がある人はどうぞ。
まとめ
本記事では再利用性の高い Tesla.Mock
の使い方を紹介しました。
Elixir ならではのテクニックを使うことで、簡単に共通化することができました!
ExUnit は Ruby の Rspec に比べると、context
階層が切れなかったり shared_context
が使えないのでテストの構造化がしにくいと思われがちです(私もそう思っていた時期がありましたw)。
しかし @tag
の機能や、テスト用の共通モジュールの実装、そしてパターンマッチを使うことで、工夫次第で Rspec のように柔軟なテストを記載することができます。
本記事のテクニックは、Elixir のテストの共通化全般に役立つ手法なので、ぜひ皆さんも採用してみて、事例をブログに書いてみてください!
それでは引き続き、Elixir Advent Calendar 2024 をお楽しみください。