💽

Phoenix で Redis を使うテストの Mock をする

2021/07/23に公開

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 を使って保存する処理はこんな感じになり

lib/app/user/one_time_token.ex
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

それを使う側は例えばこんな感じ。

lib/app/user.ex
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/1set/2 ぐらいであれば、state に Map を持つだけなので簡単に実装できる。

lib/app/redis_client.ex
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 を使って書き直すとこのようになる。

lib/app/user/one_time_token.ex
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
lib/app/user.ex
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/config.exs
config :app, :redis_client,
  {
    Redix,
    host: System.get_env("REDIS_HOST"),
    port: Integer.parse(System.get_env("REDIS_PORT")) |> elem(0),
    name: :redix
  }
config/test.exs
config :app, :redis_client, App.MockRedis
lib/app/application.ex
def start(_type, _args) do
  ...()
  
  children = [
    ...()
    Application.fetch_env!(:app, :redis_client)
  ]
  
  ...()
end

まとめ

Elixir だと GenServer を使って miniRedis っぽいものが簡単につくれてめちゃくちゃ便利。でも結局ユニットテストで使うことを考えると、テストケース毎に状態は refresh して欲しいので、state を持ったプロセスである必要はないんだよな。他の言語であれば map(dictionary) の機能を持ったクラスを作って Mock するのが良さそう。

Discussion