Phoenix で Redis を使うテストの Mock をする
Phoenix で Web アプリケーションを作っていて、そのデータストアに Redis を採用する際に、テスト実行時にどうしようという話。
テスト実行時にも Redis を起動しておけば何も気にすることはないのだけど、なるべく外部コンポーネントとの依存関係は増やしたくない。ローカルでもCIでもテスト実行のために Redis が必要なのは面倒。ということで、できれば Mock したい。
Elixir School の テストのページ にも書かれているが、Elixir では Mock は推奨されていない。このページ内でも紹介されている Mocks and explicit contracts の記事では Mock を名詞として使うのは良くなく、Mock する(動詞)として使うことを推奨している。その違いの詳細は元記事を読んで貰えばよいが、簡単にいうと、関数の返り値を書き換えるような方法は名詞としての Mock の使い方、インターフェイスを揃えて DI(Dependency Injection) をするような方法を動詞としての Mock と言っている。
課題の具体例
本題に戻って、Redis の話。Elixir で Redis クライアントとなると redix あたりを使うことになるだろうか。これを使って素直にデータストア周りを実装してみる。
例えば、ユーザにワンタイムトークンを発行して、それを保存する例を考えてみる。
Redix を使って保存する処理はこんな感じになり
defmodule App.User.OneTimeToken do
@prefix "App.User.OneTimeToken."
def get(user_id) do
Redix.command(:redix, ["GET", @prefix <> user_id])
end
def set(user_id, one_time_token) do
Redix.command(:redix, ["SET", @prefix <> user_id, one_time_token])
end
end
それを使う側は例えばこんな感じ。
defmodule App.User do
alias App.User.OneTimeToken
@doc """
発行したTokenと一致するか確認して、true/falseを返す。
一致していればそれを無効化する。
"""
def validate_token(user_id, token) do
case OneTimeToken.get(user_id) do
{:ok, ^token} ->
OneTimeToken.set(user_id, nil)
true
{:ok, _} ->
false
_ ->
false
end
end
end
このような実装のときに validate_token/2
をテストしようと思うとRedixの実装にべったりなので、冒頭で説明したような課題がある。
DI して Mock してみる
GenServer を使って、独自 KVS を作って、それを Mock する。 get/1
と set/2
ぐらいであれば、state に Map を持つだけなので簡単に実装できる。
defmodule App.RedisClientBehavior do
@callback get(key :: String.t()) ::
{:ok, result :: String.t() | nil}
| {:error, reason :: any()}
@callback set(key :: String.t(), value :: String.t()) ::
{:ok, ok :: String.t()}
| {:error, reason :: any()}
end
defmodule App.RedisClient do
@behaviour App.RedisClientBehavior
def get(key) do
Redix.command(:redix, ["GET", key])
end
def set(key, value) do
Redix.command(:redix, ["SET", key, value])
end
end
defmodule App.MockRedis do
@behaviour App.RedisClientBehavior
use GenServer
def init(state), do: {:ok, state}
def handle_call({:get, key}, _from, state) do
case Map.fetch(state, key) do
{:ok, result} -> {:reply, result, state}
:error -> {:reply, nil, state}
end
end
def handle_call({:set, key, value}, _from, state) do
{:reply, "OK", Map.put(state, key, value)}
end
### API
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def get(key), do: {:ok, GenServer.call(__MODULE__, {:get, key})}
def set(key, value) do
try do
# TODO: validate `value` is string
{:ok, GenServer.call(__MODULE__, {:set, key, value})}
catch
e -> {:error, e}
end
end
end
これに加えて timeout とか考え出すと少し複雑になるが、MockRedis
にはテストに必要最低限の機能のみ実装しておけばよいだろう。
先ほどの実装を App.RedisClient
を使って書き直すとこのようになる。
defmodule App.User.OneTimeToken do
@prefix "App.User.OneTimeToken."
def get(store, user_id) do
store.get(@prefix <> user_id)
end
def set(store, user_id, one_time_token) do
store.set(@prefix <> user_id, one_time_token)
end
end
defmodule App.User do
alias App.User.OneTimeToken
@doc """
発行したTokenと一致するか確認して、一致していればそれを無効化する
"""
def validate_token(store, user_id, token) do
case OneTimeToken.get(store, user_id) do
{:ok, ^token} ->
OneTimeToken.set(store, user_id, nil)
true
{:ok, _} ->
false
_ ->
false
end
end
end
アプリケーションコードからは validate_token/3
の第一引数に RedisClient
を、テストコードからは MockRedis
を渡して上げれば良い。
釈迦に説法だが、config で環境毎に切り替えて、起動時にRedisClientを起動させるとよいだろう。
config :app, :redis_client,
{
Redix,
host: System.get_env("REDIS_HOST"),
port: Integer.parse(System.get_env("REDIS_PORT")) |> elem(0),
name: :redix
}
config :app, :redis_client, App.MockRedis
def start(_type, _args) do
...(略)
children = [
...(略)
Application.fetch_env!(:app, :redis_client)
]
...(略)
end
まとめ
Elixir だと GenServer を使って miniRedis っぽいものが簡単につくれてめちゃくちゃ便利。でも結局ユニットテストで使うことを考えると、テストケース毎に状態は refresh して欲しいので、state を持ったプロセスである必要はないんだよな。他の言語であれば map(dictionary) の機能を持ったクラスを作って Mock するのが良さそう。
Discussion