change matcher とは
普段、しごとでは Ruby on Rails を使い、テストには RSpec を利用しています。 RSpec は matcher が充実していて、単純な一致や不一致だけでなく、いろいろなパタンの検証をすることができ重宝しています。
Elixir に標準装備の ExUnit はその点はとてもシンプルで、基本は assert とその反転の refute 、あとは例外補足などのいくつかのバリエーションが用意されているだけです。 その中にメッセージの受信の検証が用意されているのは、Elixir らしいところと言いますか、メッセージのやりとりは Elixir の基本ということを思い出させてくれます。
それぞれ勝手が違うので RSpec と ExUnit を切り替えると束の間とまどうのですが、どちらも検証したいことの記述はできますので、テストが書けなくなるようなことはありません。
ただ一つ。 RSpec にあって ExUnit になく、ExUnit にあってくれたら、と思うものが change matcher です。
RSpec では、ある式の値がある処理の前後で変化することを検証するとき、次のように簡潔に記述することができます。
# RSpec it '処理の前後で式の値が変化する' do expect { 処理 }.to change { 式 }.from(処理前の値).to(処理後の値) end
ExUnit で同じ検証をしようとすると、おおむね次のようになります。
# ExUnit test '処理の前後で式の値が変化する' do assert 処理前の値 == 式 処理 assert 処理後の値 == 式 end
また変化量を検証する場合、RSpec では:
# RSpec it '処理の前後で式の値が変化する' do expect { 処理 }.to change { 式 }.by(処理前後の変化量) end
ExUnit では:
# ExUnit test '処理の前後で式の値が変化する' do 処理前の値 = 式 処理 処理後の値 = 式 assert 処理前後の変化量 == (処理後の値 - 処理前の値) end
やりたいことはできていて不満はないのですが、同じ式を処理の前後に書かなければなりません。 できればもう少し簡単に書きたいところ。
と、いうわけで。 今回は、自前で change matcher を書いてみた、という話です。
- change matcher とは
- 準備
- Version 1: 無名関数を利用して実現する
- Version 2: 無名関数を利用して実現する その2、キーワードリストで情報を増やす
- Version 3: コードをコードとして受け取り展開する、マクロなのだから
- Version 4: キーワードリスト形式から関数形式へ
- 微調整: formatter への配慮
- いつか読むはずっと読まない:「学ぶ」を学ぶ
準備
今回は matcher を記述するアプリケーションと、その matcher を利用するアプリケーションを用意して実装を進めます。
my_matcher
- matcher を提供するアプリケーション
matcher を記述するアプリケーションの名前は my_matcher
、モジュールの名前は MyMatcher
とします。
$ mix new my_matcher
my_app
- matcher を利用するアプリケーション
matcher を利用するアプリケーションは my_app
として、my_matcher
と同じディレクトリに作成することにします。
$ mix new my_app
my_app
の mix.exs
を編集して、依存関係に my_matcher
を追加します。
defmodule MyApp.MixProject do use Mix.Project # ... 中略 defp deps do [ {:my_matcher, path: "../my_matcher", only: :test} ] end end
Version 1: 無名関数を利用して実現する
処理の前後で式を評価したいのですから、change matcher には評価前の式を渡さなければなりません。 また処理自体も、式の最初の評価の後に評価されなければなりません。
評価しない状態を値にしたいのならば、無名関数を利用するのが定番です。
そのばあい、テストは次のように書かれるはずです。
expect(fn -> 処理 end, fn -> 式 end, 処理前の値, 処理後の値) expect(fn -> 処理 end, fn -> 式 end, 処理前後の変化量)
このアイディアをもとに関数 expect/4
, expect/3
を書いてみました。
実装
defmodule MyMatcher do defmodule V1 do defmacro expect(op, exp, from, to) do quote bind_quoted: [op: op, exp: exp, from: from, to: to] do first = exp.() assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった" op.() second = exp.() refute from == second, "初期値の #{inspect(from)} から変化してない" assert to == second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した" end end defmacro expect(op, exp, by) do quote bind_quoted: [op: op, exp: exp, by: by] do first = exp.() op.() second = exp.() refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない" actual = second - first assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した" end end end end
ちなみに。 関数でなくマクロで記述しているのは次のような理由からです。
- テストに失敗したとき、スタックトレースに
expect/4
,expect/3
内のアサーションの位置が表示されてしまう(不要な情報なのでノイズになってしまう) - マクロはテスト内に展開されるので、モジュールの import の必要なく
assert/2
,refute/2
を利用できる - このあとの話の布石
これを利用してテストを書いてみます。
テストコード
関数そのものは入力に対して出力を返すだけのものですので、ある処理の前後であっても、本来は返す値は変化しません。 ですがプロセスが抱えている値は変化することがあります。
ここでは Agent
のプロセスが持つ値の変化を検証してみます。
マクロを利用するためにモジュールを import しています。 import を describe の中で行っているのは、このあと実装するバージョンとマクロが衝突しないようにするという、このあとの都合のためです。
defmodule MyAppTest do use ExUnit.Case setup do {:ok, pid} = start_supervised({Agent, fn -> 0 end}) [pid: pid] end describe "V1 expect ... to change ... from ... to ..." do import MyMatcher.V1 test "成功するテスト", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 0, 1 end test "処理前の値が正しくない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 1, 1 end test "処理後の値が正しくない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 0, 2 end test "処理後に値が変化しない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 0, 1 end end describe "V1 expect ... to change ... by ..." do import MyMatcher.V1 test "成功するテスト", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 1 end test "処理後に値が変化しない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 1 end test "処理後の値が正しくない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 2 end end end
テスト結果
テストを実行すると、期待どおりの結果がえられるこが確認できると思います。
. 1) test V1 expect ... to change ... from ... to ... 処理前の値が正しくない (MyAppTest) test/my_app_test.exs:17 初期値が 1 のはずが 0 だった code: expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 1, 1 stacktrace: test/my_app_test.exs:18: (test) 2) test V1 expect ... to change ... from ... to ... 処理後の値が正しくない (MyAppTest) test/my_app_test.exs:21 処理後に 2 へ変化するはずが 1 へ変化した code: expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 0, 2 stacktrace: test/my_app_test.exs:22: (test) 3) test V1 expect ... to change ... from ... to ... 処理後に値が変化しない (MyAppTest) test/my_app_test.exs:25 初期値の 0 から変化してない code: expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 0, 1 stacktrace: test/my_app_test.exs:26: (test) . 4) test V1 expect ... to change ... by ... 処理後に値が変化しない (MyAppTest) test/my_app_test.exs:37 処理後に値が変化するはずなのに 0 から変化していない code: expect fn -> Agent.update(pid, fn _ -> 0 end) end, fn -> Agent.get(pid, & &1) end, 1 stacktrace: test/my_app_test.exs:38: (test) 5) test V1 expect ... to change ... by ... 処理後の値が正しくない (MyAppTest) test/my_app_test.exs:41 処理後に 2 だけ値が変化するはずが 1 だけ変化した code: expect fn -> Agent.update(pid, fn _ -> 1 end) end, fn -> Agent.get(pid, & &1) end, 2 stacktrace: test/my_app_test.exs:42: (test)
Version 2: 無名関数を利用して実現する その2、キーワードリストで情報を増やす
これでテストはできますが、4 つの引数の羅列なので、テストコードの意味や意図をくみとるための情報が不足しています。 せっかくのテストなのですから、もっと説明的な記述をしたいものです。
というわけで。 第 2, 3, 4 引数をキーワードリストで受けるようにしてみます。
実装
defmodule MyMatcher do # 省略 defmodule V2 do defmacro expect(op, to_change: exp, from: from, to: to) do quote bind_quoted: [op: op, exp: exp, from: from, to: to] do first = exp.() assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった" op.() second = exp.() refute from == second, "初期値の #{inspect(from)} から変化してない" assert to == second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した" end end defmacro expect(op, to_change: exp, by: by) do quote bind_quoted: [op: op, exp: exp, by: by] do first = exp.() op.() second = exp.() refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない" actual = second - first assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した" end end end end
コードとしては、引数の前にキーワードになるアトムを追加するだけです。
ただし気をつけておきたい点は、今回定義したマクロはどちらも 2 引数のマクロ、 expect/2
という点です。
このことが影響することは少ないと思いますが、マクロにあたえる引数の数をまちがえたときに、予想外のエラーメッセージを目にして混乱するかもしれません。
テストコード
V1 のテストに、キーワード :to_change
, :from
, :to
をつけただけです。
だけですが情報が増えて、意味を理解する助けになります。
defmodule MyAppTest do use ExUnit.Case setup do {:ok, pid} = start_supervised({Agent, fn -> 0 end}) [pid: pid] end # 省略 describe "V2 expect ... to change ... from ... to ..." do import MyMatcher.V2 test "成功するテスト", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 0, to: 1 end test "処理前の値が正しくない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 1, to: 1 end test "処理後の値が正しくない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 0, to: 2 end test "処理後に値が変化しない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 0 end) end, to_change: fn -> Agent.get(pid, & &1) end, from: 0, to: 1 end end describe "V2 expect ... to change ... by ..." do import MyMatcher.V2 test "成功するテスト", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, by: 1 end test "処理後に値が変化しない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 0 end) end, to_change: fn -> Agent.get(pid, & &1) end, by: 1 end test "処理後の値が正しくない", %{pid: pid} do expect fn -> Agent.update(pid, fn _ -> 1 end) end, to_change: fn -> Agent.get(pid, & &1) end, by: 2 end end end
テストの実行結果は同じになりますので省略。
Version 3: コードをコードとして受け取り展開する、マクロなのだから
マクロは、あたえられたコード片をマクロを記述した位置に展開するしくみです。 であれば、無名関数を利用しなくても、評価前の式をそのまま扱うことができるはずです。
実装
V1 や V2 では :bind_quoted
オプションを使って、コード片を展開する前に引数をすべて評価しましたが、今回は unquote/1
を使って評価する位置に埋め込んでいます。
defmodule MyMatcher do # 省略 defmodule V3 do defmacro expect(op, to_change: exp, from: from, to: to) do quote do from = unquote(from) to = unquote(to) first = unquote(exp) assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった" unquote(op) second = unquote(exp) refute from == second, "初期値の #{inspect(from)} から変化してない" assert to === second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した" end end defmacro expect(op, to_change: exp, by: by) do quote do by = unquote(by) first = unquote(exp) unquote(op) second = unquote(exp) refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない" actual = second - first assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した" end end end end
テストコード
引数に渡す値がコード片の中に展開されそこで評価されるので、無名関数ではなく、処理そのもの式そのものを引数として直接記述できるようになりました。
defmodule MyAppTest do use ExUnit.Case setup do {:ok, pid} = start_supervised({Agent, fn -> 0 end}) [pid: pid] end # 省略 describe "V3 expect ... to change ... from ... to ..." do import MyMatcher.V3 test "成功するテスト", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), from: 0, to: 1 end test "処理前の値が正しくない", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), from: 1, to: 1 end test "処理後の値が正しくない", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), from: 0, to: 2 end test "処理後に値が変化しない", %{pid: pid} do expect Agent.update(pid, fn _ -> 0 end), to_change: Agent.get(pid, & &1), from: 0, to: 1 end end describe "V3 expect ... to change ... by ..." do import MyMatcher.V3 test "成功するテスト", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), by: 1 end test "処理後に値が変化しない", %{pid: pid} do expect Agent.update(pid, fn _ -> 0 end), to_change: Agent.get(pid, & &1), by: 1 end test "処理後の値が正しくない", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change: Agent.get(pid, & &1), by: 2 end end end
これでほぼ充分に表現できているのですが、せっかくなのでもう一段階やり過ぎてみましょう。
Version 4: キーワードリスト形式から関数形式へ
マクロに引数を渡すと、その引数は評価されずそのままの形でマクロに受け取られます。 正確には、引数は AST としてマクロに渡されています。
このことを利用すると、存在しない関数を呼び出す式を引数として渡す、ということが可能になります。
何を言っているかというと。 簡単なサンプルで説明します。
次のようなマクロを定義します。 簡単なマクロなので、ここでは IEx で入力した様子を書いています。
iex(1)> defmodule Sample do ...(1)> defmacro expect(arg1, arg2) do ...(1)> IO.inspect(arg1, label: "arg1") ...(1)> IO.inspect(arg2, label: "arg2") ...(1)> :ok ...(1)> end ...(1)> end {:module, Sample, <<70, 79, 82, 49, 0, 0, 5, 176, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 166, 0, 0, 0, 17, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101, 8, 95, 95, 105, 110, 102, 111, 95, 95, 10, 97, 116, 116, 114, 105, 98, 117, 116, 101, 115, 7, 99, 111, 109, 112, 105, 108, 101, 10, 100, 101, 112, 114, 101, 99, 97, 116, 101, 100, 11, 101, 120, 112, 111, 114, 116, 115, 95, 109, 100, 53, 9, 102, 117, 110, 99, 116, 105, 111, 110, 115, 6, 109, 97, 99, 114, 111, 115, 3, 109, 100, 53, 6, 109, 111, 100, 117, 108, 101, 6, 101, 114, 108, 97, 110, 103, ...>>, {:expect, 2}}
マクロを import し、そのマクロの引数に foo("abc")
や bar(12345, 678900)
といった存在しない関数を呼び出す式を渡します。
面白いことに undefined function foo/1
のようなエラーメッセージは表示されず、引数に渡した関数呼び出しの AST が表示されます。
iex(2)> import Sample Sample iex(3)> expect(foo("abc"), bar(12345, 67890)) arg1: {:foo, [line: 4], ["abc"]} arg2: {:bar, [line: 4], [12345, 67890]} :ok
ドキュメントにあるように、関数の呼び出しは {関数名のアトム, メタデータ, 引数のリスト}
という形式になっていることがわかります。
実はこれも引数のパタンマッチングに使えます。
再びサンプルです。
expect(foo("abc"), bar("ABC"))
という呼び出しに対して値の評価はせず、関数名はパタンマッチングにだけ利用しています。
iex(1)> defmodule Sample do ...(1)> defmacro expect({:foo, _, [arg1]}, {:bar, _, [arg2]}) do ...(1)> quote do ...(1)> [foo: unquote(arg1), bar: unquote(arg2)] ...(1)> end ...(1)> end ...(1)> end {:module, Sample, <<70, 79, 82, 49, 0, 0, 5, 116, 66, 69, 65, 77, 65, 116, 85, 56, 0, 0, 0, 153, 0, 0, 0, 16, 13, 69, 108, 105, 120, 105, 114, 46, 83, 97, 109, 112, 108, 101, 8, 95, 95, 105, 110, 102, 111, 95, 95, 10, 97, 116, 116, 114, 105, 98, 117, 116, 101, 115, 7, 99, 111, 109, 112, 105, 108, 101, 10, 100, 101, 112, 114, 101, 99, 97, 116, 101, 100, 11, 101, 120, 112, 111, 114, 116, 115, 95, 109, 100, 53, 9, 102, 117, 110, 99, 116, 105, 111, 110, 115, 6, 109, 97, 99, 114, 111, 115, 3, 109, 100, 53, 6, 109, 111, 100, 117, 108, 101, 6, 101, 114, 108, 97, 110, 103, ...>>, {:expect, 2}}
iex(2)> import Sample Sample iex(3)> expect(foo("abc"), bar("ABC")) [foo: "abc", bar: "ABC"]
と、いうことは。
これと同じように to_change(式)
という記述に対してパタンマッチさせ、式のみを利用するということが可能なはずです。
実装
V3 でキーワードリストになっていた部分を、関数呼び出しの AST のパタンマッチングに置き換えます。
defmodule MyMatcher do # 省略 defmodule V4 do defmacro expect(op, {:to_change, _, [exp]}, {:from, _, [from]}, {:to, _, [to]}) do quote do from = unquote(from) to = unquote(to) first = unquote(exp) assert from == first, "初期値が #{inspect(from)} のはずが #{inspect(first)} だった" unquote(op) second = unquote(exp) refute from == second, "初期値の #{inspect(from)} から変化してない" assert to === second, "処理後に #{inspect(to)} へ変化するはずが #{inspect(second)} へ変化した" end end defmacro expect(op, {:to_change, _, [exp]}, {:by, _, [by]}) do quote do by = unquote(by) first = unquote(exp) unquote(op) second = unquote(exp) refute first == second, "処理後に値が変化するはずなのに #{inspect(first)} から変化していない" actual = second - first assert by == actual, "処理後に #{inspect(by)} だけ値が変化するはずが #{inspect(actual)} だけ変化した" end end end end
テストコード
defmodule MyAppTest do use ExUnit.Case setup do {:ok, pid} = start_supervised({Agent, fn -> 0 end}) [pid: pid] end # 省略 describe "V4" do import MyMatcher.V4 test "成功するテスト", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), from(0), to(1) end test "処理前の値が正しくない", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), from(1), to(1) end test "処理後の値が正しくない", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), from(0), to(2) end test "処理後に値が変化しない", %{pid: pid} do expect Agent.update(pid, fn _ -> 0 end), to_change(Agent.get(pid, & &1)), from(0), to(1) end end describe "V4 expect ... to change ... by ..." do import MyMatcher.V4 test "成功するテスト", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), by(1) end test "処理後に値が変化しない", %{pid: pid} do expect Agent.update(pid, fn _ -> 0 end), to_change(Agent.get(pid, & &1)), by(1) end test "処理後の値が正しくない", %{pid: pid} do expect Agent.update(pid, fn _ -> 1 end), to_change(Agent.get(pid, & &1)), by(2) end end end
あらためて Rspec の記述とくらべてみましょう。
# RSpec it '処理の前後で式の値が変化する' do expect { 処理 }.to change { 式 }.from(処理前の値).to(処理後の値) end
# ExUnit test '処理の前後で式の値が変化する' do expect 処理, to_change(式), from(処理前の値), to(処理後の値) end
これで RSpec で見慣れた形に近づけることができたのではないかと思います。
微調整: formatter への配慮
この状態で mix format
を実行すると、expect
に括弧が追加されてしまいます。
# `mix format` を実行すると、括弧が追加される
expect(処理, to_change(式), from(処理前の値), to(処理後の値))
テストでは、ここまで書いてきたように、括弧なしの表現のままにしておきたいものです。
そこで .formatter.exs
を修正して expect/2
, expect/3
, expect/4
には括弧を追加しない設定を加えます。
mix new
コマンドを実行すると、次のような内容の .formatter.exs
ファイルが作成されます。
[ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ]
ここに :locals_without_parens
オプションで括弧を追加したくない関数を並べます。
[ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], locals_without_parens: [expect: 2, expect: 3, expect: 4] ]
このオプションの詳細については、ドキュメントを参照ください。
いつか読むはずっと読まない:「学ぶ」を学ぶ
本文に、メタ認知が重要、とありました。
わたしにとってブログを書くことは「『自分の理解』を理解」する手続きなのかもしれません。