Elixir Circuits.I2C with Mox

This is written in Japanese. I might convert it to English later, maybe.

はじめに

Elixirのテストでモックを用意するときに利用するElixirパッケージとして、moxが人気です。Elixir作者のJosé Valimさんが作ったからということもありますが、ただモックを用意するだけではなくElixirアプリの構成をより良くするためのアイデアにまで言及されているので、教科書のようなものと思っています。

一言でいうと「その場しのぎのモックはするべきではない」ということです。モックが必要となる場合はまず契約(behaviour)をしっかり定義し、それをベースにモックを作ります。結果としてコードの見通しもよくなると考えられます。そういった考え方でのモックを作る際に便利なのがmoxです。

しかしながら、moxの設定方法は(慣れるまでは)あまり直感的ではなく、おそらく初めての方はとっつきにくそうな印象を持つと思います。自分なりに試行錯誤して導き出した簡単でわかりやすいmoxの使い方があるので、今日はそれをご紹介させていだだこうと思います。いろいろなやり方があるうちの一例です。

例として、circuits_i2cを用いて温度センサーと通信するElixirコードのモックを考えてみます。

Elixirのリモートもくもく会autoracexでおなじみのオーサムさん(@torifukukaiou)が以前こうおっしゃってました。

原典をあたるが一番だとおもいます。 原典にすべて書いてある。

まずはJosé Valimさんの記事ドキュメントを一通り読んで頂いて、その上で戸惑った際の一助になれば幸いです。

依存関係

  • moxをインストール。
  • 契約をしっかり定義するためには、それ以前にがちゃんと定義されている必要があります。ですので理想としてはdialyxirで型チェックした方が良いと個人的には考えてます。
# mix.exs

    ...
    defp deps do
      [
        ...
+       {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false},
+       {:mox, "~> 1.0", only: :test},
        ...
      ]
    end
    ...
$ cd path/to/my_app
$ mix deps.get

Circuits.I2C

  • I2C通信するのに便利なElixirパッケージ。
  • 例えば、ElixirアプリからI2Cに対応するセンサーと通信するのに使える。
  • センサーが相手のアプリで、どのようにしてセンサーの無いテスト環境でアプリをイゴかせるようにするかが課題。

Circuits.I2Cに定義されている関数を確認してみます。これらの関数でセンサーを用いて通信します。

Circuits.I2C.open(bus_name)
Circuits.I2C.read(i2c_bus, address, bytes_to_read, opts \\ [])
Circuits.I2C.write(i2c_bus, address, data, opts \\ [])
Circuits.I2C.write_read(i2c_bus, address, write_data, bytes_to_read, opts \\ [])

以降でCircuits.I2Cをモックに入れ替えできように工夫して実装していきます。

behaviourを定義する

  • まずは「データ転送する層」の契約を定義します。どう定義するかは任意です。
  • 例として、個人的に気に入っているパターンを挙げます。
# lib/my_app/transport.ex

defmodule MyApp.Transport do
  defstruct [:ref, :bus_address]

  ## このモジュールで使用される型

  @type t ::
          %__MODULE__{ref: reference(), bus_address: 0..127}

  @type option ::
          {:bus_name, String.t()} | {:bus_address, 0..127}

  ## このbehaviourの要求する関数の型

  @callback open([option()]) ::
              {:ok, t()} | {:error, any()}

  @callback read(t(), pos_integer()) ::
              {:ok, binary()} | {:error, any()}

  @callback write(t(), iodata()) ::
              :ok | {:error, any()}

  @callback write_read(t(), iodata(), pos_integer()) ::
              {:ok, binary()} | {:error, any()}
end

behaviourを実装する(本番用)

  • 実際のセンサーに接続することを想定した実装。
  • 記述量が比較的少ない場合、僕は便宜上behaviourの定義と同じファイルにまとめることが多いです。
# lib/my_app/transport.ex

defmodule MyApp.Transport.I2C do
  @behaviour MyApp.Transport

  @impl MyApp.Transport
  def open(opts) do
    bus_name = Access.fetch!(opts, :bus_name)
    bus_address = Access.fetch!(opts, :bus_address)

    case Circuits.I2C.open(bus_name) do
      {:ok, ref} ->
        {:ok, %MyApp.Transport{ref: ref, bus_address: bus_address}}

      {:error, reason} ->
        {:error, reason}
    end
  end

  @impl MyApp.Transport
  def read(transport, bytes_to_read) do
    Circuits.I2C.read(transport.ref, transport.bus_address, bytes_to_read)
  end

  @impl MyApp.Transport
  def write(transport, data) do
    Circuits.I2C.write(transport.ref, transport.bus_address, data)
  end

  @impl MyApp.Transport
  def write_read(transport, data, bytes_to_read) do
    Circuits.I2C.write_read(transport.ref, transport.bus_address, data, bytes_to_read)
  end
end

behaviourを実装する(スタブ)

  • センサーがない環境での実行を想定した実装。
  • モックの基本的な振る舞いを定義。
  • モック自体は空のモジュールで、スタブがモックに振る舞いを与えるイメージ。
  • 引数はarityさえあっていればOK。
  • behaviourの型に合う正常系の値を返すようにする。
  • 記述量が比較的少ない場合、僕は便宜上behaviourと同じファイルにまとめることが多いです。
  • どうしてもtest配下に置きたい場合は、test/supportを用意するやり方moxのドキュメントに紹介されてます。
# lib/my_app/transport.ex

defmodule MyApp.Transport.Stub do
  @behaviour MyApp.Transport

  @impl MyApp.Transport
  def open(_opts) do
    {:ok, %MyApp.Transport{ref: make_ref(), bus_address: 0x00}}
  end

  @impl MyApp.Transport
  def read(_transport, _bytes_to_read) do
    {:ok, "stub"}
  end

  @impl MyApp.Transport
  def write(_transport, _data) do
    :ok
  end

  @impl MyApp.Transport
  def write_read(_transport, _data, _bytes_to_read) do
    {:ok, "stub"}
  end
end

モックのモジュールを準備する

  • テスト用にモックのモジュールを準備する。
  • MyApp.Transportの実装(:transport_mod)を入れ替えできるようにする。
  • テスト時に:transport_modをモック(MyApp.MockTransport)にしておく。
# test/test_helper.exs

+ Mox.defmock(MyApp.MockTransport, for: MyApp.Transport)

+ Application.put_env(:aht20, :transport_mod, MyApp.MockTransport)

  ExUnit.start()

MyApp.Transportを用いてアプリを書いてみる

例えば温度をセンサーのから読み込むGenServerを書くとこんな感じになります。

# lib/my_app.ex

defmodule MyApp do
  use GenServer

  @type option() :: {:name, GenServer.name()} | {:bus_name, String.t()}

  @spec start_link([option()]) :: GenServer.on_start()
  def start_link(init_arg \\ []) do
    GenServer.start_link(__MODULE__, init_arg, name: init_arg[:name])
  end

  @spec measure(GenServer.server()) :: {:ok, MyApp.Measurement.t()} | {:error, any()}
  def measure(server), do: GenServer.call(server, :measure)

  @impl GenServer
  def init(config) do
    bus_name = config[:bus_name] || "i2c-1"

    # ここでモジュールを直書きしないこと!
    case transport_mod().open(bus_name: bus_name, bus_address: 0x38) do
      {:ok, transport} ->
        {:ok, %{transport: transport}, {:continue, :init_sensor}}

      error ->
        raise("Error opening i2c: #{inspect(error)}")
    end
  end

  ...

  # 動的にモジュールを入れ替えする関数。
  # これをモジュール属性(@transport_mod)として定義してしまうとコンパイル時に固定されてしまう
  # ので注意が必要です。関数にしておくとが実行時に評価されるので直感的で無難と思います。
  defp transport_mod() do
    Application.get_env(:my_app, :transport_mod, MyApp.Transport.I2C)
  end
end

モックを用いてテストを書いてみる

  • import Moxmoxの関数を使えるようになります。
  • setupはおまじないです。
# test/my_app_test.exs

defmodule MyAppTest do
  use ExUnit.Case

  import Mox

  setup :set_mox_from_context

  setup :verify_on_exit!

  setup do
    # モックにスタブをセットする。これでセンサーがなくてもコードがイゴくようになります。
    Mox.stub_with(MyApp.MockTransport, MyApp.Transport.Stub)
    :ok
  end

  ...
# test/my_app_test.exs

test "measure" do
  # 各テストでexpectを用いて具体的にどの関数がどのようにして何度呼ばれることが「期待されるか」を指定。
  MyApp.MockTransport
  |> Mox.expect(:read, 1, fn _transport, _data ->
    {:ok, <<28, 113, 191, 6, 86, 169, 149>>}
  end)

  assert {:ok, pid} = MyApp.start_link()
  assert {:ok, measurement} = MyApp.measure(pid)

  assert %MyApp.Measurement{
            humidity_rh: 44.43206787109375,
            temperature_c: 29.23145294189453,
            timestamp_ms: _
          } = measurement
end

test "measure when read failed" do
  MyApp.MockTransport
  |> Mox.expect(:read, 1, fn _transport, _data ->
    {:error, "Very bad"}
  end)

  assert {:ok, pid} = MyApp.start_link()
  assert {:error, "Very bad"} = MyApp.measure(pid)
end

今回ご紹介したパターンはAHT20のElixirパッケージでバリバリ活躍しています。

以上! :tada::tada::tada: