BehaviourとMix.Configで切り替え可能なStubを実装する


(この記事は、「fukuoka.ex Elixir/Phoenix Advent Calendar Advent Calendar 2018」の12日目です)

昨日は @twinbee さんのgrpc-elixirでGoと通信してみる #1でした。

今日は私からElixirでのStub実装のお話です。

Stub実装が欲しくなるケース

業務システムの開発をしていると、よそ様の開発したシステムに連結しなければ処理が実行できない要件などがしばしば登場します。
そうした場合、テスト実行のたびに相手のシステムへアクセスしたり、更新をかけたりすることが望ましくない場合も多く、自動テストの妨げになる事もあります。

そういったケースにおいてAdapterとStubを実装しておく事でテスト時の好ましくない依存関係を解消することができます。
以下で実装の流れを解説します。

プロジェクトを作成する

まずはサンプル実装のためのプロジェクトを実装します。
Elixirのバージョン確認します。

> elixir --version
Erlang/OTP 20 [erts-9.2.1] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Elixir 1.6.1 (compiled with OTP 20)

Phoenixプロジェクトを作成します。

> mix phx.new stub_sample --no-ecto --no-brunch
> cd stub_sample

mix定義に必要なライブラリを追加してdeps.getします。

mix.exs
def application do
    [
      mod: {StubSample.Application, []},
      extra_applications: [:logger, :runtime_tools, :httpoison] # <- :httpoison追加
    ]
end

defp deps do
    [
      {:phoenix, "~> 1.3.2"},
      {:phoenix_pubsub, "~> 1.0"},
      {:phoenix_html, "~> 2.10"},
      {:phoenix_live_reload, "~> 1.0", only: :dev},
      {:gettext, "~> 0.11"},
      {:cowboy, "~> 1.0"},
      {:poison, "~> 3.0"}, # <- 追加
      {:httpoison, "~> 1.4"}, # <- 追加
    ]
  end
> mix deps.get

まずは直接実装してみる

httpoisonを使ってQiitaのタイトル一覧を取得する簡単な処理を実装してみます。

lib/stub_sample/api_adapters/api_adapter_qiita.ex

defmodule StubSample.ApiAdapter.Qiita do

  def list_items(url) do
    resp = HTTPoison.get! url
    {:ok, contents} = Poison.decode(resp.body)
    Enum.map(contents, fn(content) -> content["title"] end)
  end

end

実行するとタイトル一覧が取得できます。

iex(1)> StubSample.ApiAdapter.Qiita.list_items("https://qiita.com/api/v2/items?page=1&per_page=10&query=Fukuoka.ex")
["【超残念】fukuoka.ex Elixir/Phoenix Advent Calendar 2018参加を断念",
 "[Elixir]Calendarモジュールを使って、DataFormatを作る方法を色々と試す。",
 "grpc-elixirでGoと通信してみる #1",
 "Elixirでニューラルネットワークを実装しようとした話",
 "Elixir(エリクサー)で数値計算すると幸せになれる",
 "プログラマでも「Figma」ならレイアウトやロゴが簡単にデザインできる",
 "\"show weather\" コマンド作ってみた",
 "データサイエンスプラットフォームEsunaの前処理UI/Elixirコードは、入力データ解析の後、データ内容から自動生成されている",
 "RustElixirで線形回帰を高速化した話",
 "【doctestつき】AtCoder に登録したら解くべき精選過去問 10 問を\"Elixir\"で解いてみた"]

AdapterとStubを実装する

先ほどの実装では、Qiitaにアクセスできない状態ではテストができません。
独立したテストができるようにAdapterとStubモジュールを実装していきます。

callbackとしてlist_items()/1を定義したモジュールを実装します。

lib/stub_sample/api_adapters/api_adapter.ex
defmodule StubSample.ApiAdapter do

  @callback list_items(url :: String) :: List

end

Qiitaアダプターにbehaviour定義を追加します。

defmodule StubSample.ApiAdapter.Qiita do

  @behaviour StubSample.ApiAdapter # <- 追記

  def list_items(url) do
    resp = HTTPoison.get! url
    {:ok, contents} = Poison.decode(resp.body)
    Enum.map(contents, fn(content) -> content["title"] end)
  end

end

同じくStubSample.ApiAdapterを定義してlist_items()/1を実装したStubモジュールを実装します。

lib/stub_sample/api_adapters/api_adapter_stub.ex
defmodule StubSample.ApiAdapter.Stub do

  @behaviour StubSample.ApiAdapter

  def list_items(url) do
    ["#{__MODULE__} stub was worked! #{url}"]
  end

end

上記の実装を呼ぶビジネスロジックを想定したモジュールを実装します。

list_itemsを実行するモジュールは直接aliasで定義せず、
configから取得するようにしています。

lib/stub_sample/api_call_sample.ex
defmodule StubSample.ApiCallSample do

  @adapter Application.get_env(:stub_sample, StubSample.ApiAdapter)[:adapter_module]

  def api_call(url) do

    @adapter.list_items(url)

  end

end

Configを定義する

ApiAdapterの実装モジュールとして
devではStubSample.ApiAdapter.Qiitaを、
testではStubSample.ApiAdapter.Stub
を定義します。

config/dev.exs
config :stub_sample, StubSample.ApiAdapter,
  adapter_module: StubSample.ApiAdapter.Qiita
config/test.exs
config :stub_sample, StubSample.ApiAdapter,
  adapter_module: StubSample.ApiAdapter.Stub

実行してみる

まずはMIX_ENVを指定せずに(MIX_ENV=dev)で実行してみるとStubSample.ApiAdapter.Qiitaの処理が実行されます。

> iex -S mix
iex(1)> StubSample.ApiCallSample.api_call("https://qiita.com/api/v2/items?page=1&per_page=10&query=Fukuoka.ex")
["【超残念】fukuoka.ex Elixir/Phoenix Advent Calendar 2018参加を断念",
 "[Elixir]Calendarモジュールを使って、DataFormatを作る方法を色々と試す。",
 "grpc-elixirでGoと通信してみる #1",
 "Elixirでニューラルネットワークを実装しようとした話",
 "Elixir(エリクサー)で数値計算すると幸せになれる",
 "プログラマでも「Figma」ならレイアウトやロゴが簡単にデザインできる",
 "\"show weather\" コマンド作ってみた",
 "データサイエンスプラットフォームEsunaの前処理UI/Elixirコードは、入力データ解析の後、データ内容から自動生成されている",
 "RustElixirで線形回帰を高速化した話",
 "【doctestつき】AtCoder に登録したら解くべき精選過去問 10 問を\"Elixir\"で解いてみた"]

次に、MIX_ENV=test で実行すると、StubSample.ApiAdapter.Stubの処理が実装されます。

> MIX_ENV=test iex -S mix
iex(1)> StubSample.ApiCallSample.api_call("https://qiita.com/api/v2/items?page=1&per_page=10&query=Fukuoka.ex")
["Elixir.StubSample.ApiAdapter.Stub stub was worked! https://qiita.com/api/v2/items?page=1&per_page=10&query=Fukuoka.ex"]

(2018/12/13 実行イメージがStubを直接実行しているケースを記載していたので修正しました。)

まとめ

  • @call_back と @behaviour で振る舞いを定義
  • cofigとApplication.get_env()を使えば環境毎にモジュールを切り替えることができる。

将来的に複数のサービスを環境毎に切り替えるようなケースでもこのパターンは使えるかと思います。

明日のfukuoka.ex Elixir/Phoenix Advent Calendar 2018 14日目の記事は, @kikuyuta さんの階段の上でも下でも電灯を点けたり消したりするです。こちらもお楽しみに!