エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

ExUnit で change matcher を使いたい、ので、書いてみた

change matcher とは

普段、しごとでは Ruby on Rails を使い、テストには RSpec を利用しています。 RSpec は matcher が充実していて、単純な一致や不一致だけでなく、いろいろなパタンの検証をすることができ重宝しています。

Elixir に標準装備の ExUnit はその点はとてもシンプルで、基本は assert とその反転の refute 、あとは例外補足などのいくつかのバリエーションが用意されているだけです。 その中にメッセージの受信の検証が用意されているのは、Elixir らしいところと言いますか、メッセージのやりとりは Elixir の基本ということを思い出させてくれます。

それぞれ勝手が違うので RSpec と ExUnit を切り替えると束の間とまどうのですが、どちらも検証したいことの記述はできますので、テストが書けなくなるようなことはありません。

ただ一つ。 RSpec にあって ExUnit になく、ExUnit にあってくれたら、と思うものが change matcher です。

relishapp.com

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 を書いてみた、という話です。

準備

今回は 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_appmix.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

ちなみに。 関数でなくマクロで記述しているのは次のような理由からです。

  1. テストに失敗したとき、スタックトレースexpect/4, expect/3 内のアサーションの位置が表示されてしまう(不要な情報なのでノイズになってしまう)
  2. マクロはテスト内に展開されるので、モジュールの import の必要なく assert/2, refute/2 を利用できる
  3. このあとの話の布石

これを利用してテストを書いてみます。

テストコード

関数そのものは入力に対して出力を返すだけのものですので、ある処理の前後であっても、本来は返す値は変化しません。 ですがプロセスが抱えている値は変化することがあります。

ここでは 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 としてマクロに渡されています。

hexdocs.pm

このことを利用すると、存在しない関数を呼び出す式を引数として渡す、ということが可能になります。

何を言っているかというと。 簡単なサンプルで説明します。

次のようなマクロを定義します。 簡単なマクロなので、ここでは 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]
]

このオプションの詳細については、ドキュメントを参照ください。

hexdocs.pm hexdocs.pm

いつか読むはずっと読まない:「学ぶ」を学ぶ

本文に、メタ認知が重要、とありました。

わたしにとってブログを書くことは「『自分の理解』を理解」する手続きなのかもしれません。