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さんの記事とドキュメントを一通り読んで頂いて、その上で戸惑った際の一助になれば幸いです。
依存関係
# 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.exdefmoduleMyApp.Transportdodefstruct[:ref,:bus_address]## このモジュールで使用される型@typet::%__MODULE__{ref:reference(),bus_address:0..127}@typeoption::{:bus_name,String.t()}|{:bus_address,0..127}## このbehaviourの要求する関数の型@callbackopen([option()])::{:ok,t()}|{:error,any()}@callbackread(t(),pos_integer())::{:ok,binary()}|{:error,any()}@callbackwrite(t(),iodata()):::ok|{:error,any()}@callbackwrite_read(t(),iodata(),pos_integer())::{:ok,binary()}|{:error,any()}end
behaviourを実装する(本番用)
- 実際のセンサーに接続することを想定した実装。
- 記述量が比較的少ない場合、僕は便宜上behaviourの定義と同じファイルにまとめることが多いです。
# lib/my_app/transport.exdefmoduleMyApp.Transport.I2Cdo@behaviourMyApp.Transport@implMyApp.Transportdefopen(opts)dobus_name=Access.fetch!(opts,:bus_name)bus_address=Access.fetch!(opts,:bus_address)caseCircuits.I2C.open(bus_name)do{:ok,ref}->{:ok,%MyApp.Transport{ref:ref,bus_address:bus_address}}{:error,reason}->{:error,reason}endend@implMyApp.Transportdefread(transport,bytes_to_read)doCircuits.I2C.read(transport.ref,transport.bus_address,bytes_to_read)end@implMyApp.Transportdefwrite(transport,data)doCircuits.I2C.write(transport.ref,transport.bus_address,data)end@implMyApp.Transportdefwrite_read(transport,data,bytes_to_read)doCircuits.I2C.write_read(transport.ref,transport.bus_address,data,bytes_to_read)endend
behaviourを実装する(スタブ)
- センサーがない環境での実行を想定した実装。
- モックの基本的な振る舞いを定義。
- モック自体は空のモジュールで、スタブがモックに振る舞いを与えるイメージ。
- 引数はarityさえあっていればOK。
- behaviourの型に合う正常系の値を返すようにする。
- 記述量が比較的少ない場合、僕は便宜上behaviourと同じファイルにまとめることが多いです。
- どうしても
test
配下に置きたい場合は、test/support
を用意するやり方がmoxのドキュメントに紹介されてます。
# lib/my_app/transport.exdefmoduleMyApp.Transport.Stubdo@behaviourMyApp.Transport@implMyApp.Transportdefopen(_opts)do{:ok,%MyApp.Transport{ref:make_ref(),bus_address:0x00}}end@implMyApp.Transportdefread(_transport,_bytes_to_read)do{:ok,"stub"}end@implMyApp.Transportdefwrite(_transport,_data)do:okend@implMyApp.Transportdefwrite_read(_transport,_data,_bytes_to_read)do{:ok,"stub"}endend
モックのモジュールを準備する
- テスト用にモックのモジュールを準備する。
- 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.exdefmoduleMyAppdouseGenServer@typeoption()::{:name,GenServer.name()}|{:bus_name,String.t()}@specstart_link([option()])::GenServer.on_start()defstart_link(init_arg\\[])doGenServer.start_link(__MODULE__,init_arg,name:init_arg[:name])end@specmeasure(GenServer.server())::{:ok,MyApp.Measurement.t()}|{:error,any()}defmeasure(server),do:GenServer.call(server,:measure)@implGenServerdefinit(config)dobus_name=config[:bus_name]||"i2c-1"# ここでモジュールを直書きしないこと!casetransport_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)}")endend...# 動的にモジュールを入れ替えする関数。# これをモジュール属性(@transport_mod)として定義してしまうとコンパイル時に固定されてしまう# ので注意が必要です。関数にしておくとが実行時に評価されるので直感的で無難と思います。defptransport_mod()doApplication.get_env(:my_app,:transport_mod,MyApp.Transport.I2C)endend
モックを用いてテストを書いてみる
import Mox
でmoxの関数を使えるようになります。setup
はおまじないです。
# test/my_app_test.exsdefmoduleMyAppTestdouseExUnit.CaseimportMoxsetup:set_mox_from_contextsetup:verify_on_exit!setupdo# モックにスタブをセットする。これでセンサーがなくてもコードがイゴくようになります。Mox.stub_with(MyApp.MockTransport,MyApp.Transport.Stub):okend...
# test/my_app_test.exstest"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:_}=measurementendtest"measure when read failed"doMyApp.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パッケージでバリバリ活躍しています。
以上!
🎉🎉🎉
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse