こんにちは、プロダクトチームのソーントンです。
社内の「1人プロジェクト」という取り組みで1ヶ月で社内ツールを作る機会があったので、初めての言語Elixirに初挑戦してみました。
その中でMoxを使ったTDDが楽しかったので、ご紹介します。
Elixirの単体テストの基礎
Elixirでは単体テストにExUnitを使います。
defmodule GreetingTest do use ExUnit.Case test "いつでも成功" do assert true end test "指定した言語でこんにちは" do assert Greeting.hello_in(:japanese) == "こんにちは" end end
テストを書く上でのポイントとしては以下だけです!
use ExUnit.Case
を記述することtest
(describe
もある) 内にテスト本文を書くassert
マクロを使ってアサーションを書く
mixプロジェクトではtest
ディレクトリに ***_test.exs
のようなファイル名でファイルを追加すると勝手にテスト対象になり、mix test
で実行できます。
簡単ですね!
実際のプロジェクトでは?
モジュール同士の依存が発生するような実際のプロジェクトではさすがにもっと複雑になります。
Elixirのビヘイビアとコールバックという仕組みでモジュール間を疎結合にしつつ、モックライブラリを使ってアサーションをした例を紹介します。
ビヘイビアとコールバック
ビヘイビアは関数の定義のリストです。あるモジュールがビヘイビアを実装すると宣言する場合に、そのリストの関数全てが実装されることを保証します。
以下の例では MyApp.SlackClientBehaviour
がビヘイビアで @callback
を使って関数の定義をしています。また、MyApp.SlackClient
は@behaviour
を使ってMyApp.SlackClientBehaviour
を実装すると宣言し、実際に実装を提供しています。
# lib/my_app/slack_client.ex defmodule MyApp.SlackClientBehaviour do @callback post_message(message :: String.t()) :: {:ok, thread_id :: String.t()} end defmodule MyApp.SlackClient do @behaviour MyApp.SlackClientBehaviour def post_message(message) do # do something end end
上記のように振る舞いの定義と実装を別にしておくと、モジュールAがモジュールBに依存する時に、実装ではなく型だけに依存することができます。
先程の例で定義したモジュールを使う場合には、以下のようにconfigファイルで実装モジュールを宣言しておくと、 Application.get_env
で使用することができます。
# config/config.exs config :my_app, slack_client: MyApp.SlackClient
# lib/my_app/main.ex defmodule MyApp.Main do @slack_client Application.get_env(:my_app, :slack_client) def send_messages do # do somethinkg with @slack_client end end
ビヘイビア x 単体テスト
このようにビヘイビアを利用すると、実装モジュールを直接知らなくても使用することができるようになるので、「単体テストの文脈では異なる実装モジュールを使う」という選択肢が取れるようになります。
以下のようにconfigを書くと、テスト実行時は MyApp.SlackClientForTest
モジュール、それ以外の時は MyApp.SlackClient
を使用することができます。
# config/config.exs case Mix.env() do :test -> config :my_app, slack_client: MyApp.SlackClientForTest _ -> config :my_app, slack_client: MyApp.SlackClient end
MyApp.SlackClientForTest
は MyApp.SlackClientBehaviour
を実装しているものであればなんでも良いので、自前で作成したモジュールでも良いですし、モックライブラリを使って作成したものでも良いです。
Mox登場!
テスト対象の関数が依存モジュールの別の関数を意図した通りに呼んだかどうかを調べるには、モックライブラリを使うのが便利です。今回はMoxを使用して見ましょう。
mix.exs
のdepsに moxを足して deps.get
をした後に下記を書いてみます
# test/mock/mocks.ex Mox.defmock(MyApp.SlackClientMock, for: MyApp.SlackClientBehaviour )
MyApp.SlackClientMock
はMyApp.SlackClientBehaviour
を実装しているという宣言ですね。
先程のmocks.exがコンパイルされるようにするために、mix.exsも少し修正します。
# mix.exs def project do [ ... elixirc_paths: elixirc_paths(Mix.env), ... ] end defp elixirc_paths(:test), do: ["test/mock", "lib"] defp elixirc_paths(_), do: ["lib"]
さらに、テスト実行時にはMyApp.SlackClientMock
を使うように修正します。
# config/config.exs use Mix.Config case Mix.env() do :test -> config :my_app, slack_client: MyApp.SlackClientMock _ -> config :my_app, slack_client: MyApp.SlackClient end
そして、以下が実際のテストコードです。
# test/my_app/main_test.exs defmodule MyApp.MainTest do use ExUnit.Case import Mox setup :verify_on_exit! alias MyApp.Main alias MyApp.SlackClientMock test "send multiple messages" do SlackClientMock |> expect(:post_message, fn "Hello" -> {:ok, "thread_id1"} end) |> expect(:post_message, fn "World" -> {:ok, "thread_id2"} end) Main.send_messages() end end
上記のように書いた場合は、send_message
内でpost_message
を2回(それぞれ"Hello","World"を引数に)呼び出していることを保証しています。
setup :verify_on_exit!
を書くことによって、このテストが終わる時に用意したexpectが全て呼ばれていることを確認してくれるようになります。
ちなみにexpect/4
に関しては、第3引数に呼ばれるべき回数(デフォルトは1)も設定できます・
expect/4
だけで、どのメソッドがどの引数で何回呼ばれて何を返すかの宣言が完結するのが良いところだと考えていて、個人的には第4引数の関数にパターンマッチングを使うとシンプルに書けるということに感動しました・・!
テストの落ち方
テストが正しく落ちていることを確認するのもTDDでは大事な要素なので、落ち方もみてみましょう。
send_messages
の実装を何も変えずに (=post_message
を全く呼ばない状態で)実行すると以下のようなログがでて、 post_message
が一切呼ばれていないことがわかります。
1) test send multiple messages (MyApp.MainTest) test/my_app/main_test.exs:9 ** (Mox.VerificationError) error while verifying mocks for #PID<0.216.0>: * expected MyApp.SlackClientMock.post_message/1 to be invoked 2 times but it was invoked 0 times stacktrace: (mox 1.0.1) lib/mox.ex:713: Mox.verify_mock_or_all!/3 (ex_unit 1.12.3) lib/ex_unit/on_exit_handler.ex:143: ExUnit.OnExitHandler.exec_callback/1 (ex_unit 1.12.3) lib/ex_unit/on_exit_handler.ex:129: ExUnit.OnExitHandler.on_exit_runner_loop/0
以下のように変えて、テストがexpectationの引数で落ちるようにしてみましょう。
defmodule MyApp.Main do @slack_client Application.get_env(:my_app, :slack_client) def send_messages do @slack_client.post_message("Hello") @slack_client.post_message("12345") # "World"が正しい end end
テストを実行すると、post_message
に"12345"が渡されていることがわかる形でログが出力されます。
1) test send multiple messages (MyApp.MainTest) test/my_app/main_test.exs:9 ** (FunctionClauseError) no function clause matching in anonymous fn/1 in MyApp.MainTest."test send multiple messages"/1 The following arguments were given to anonymous fn/1 in MyApp.MainTest."test send multiple messages"/1: # 1 "12345" code: Main.send_messages() stacktrace: test/my_app/main_test.exs:12: anonymous fn/1 in MyApp.MainTest."test send multiple messages"/1 test/my_app/main_test.exs:14: (test)
expect
を使うと正しく落ちていることが確認できることがわかりました。
まとめ
ElixirのビヘイビアとMoxを使って単体テストを書く方法をご紹介しました。
ビヘイビアによる型の定義とシンプルなexpectationの記述によって、テストしたい内容にフォーカスしたテストが書きやすいと思ったのが私の感想です。
みなさんも機会があれば試してみてください!良いTDDライフを〜
おわりに
ユーザベースではエンジニアを募集しています。ご興味ある方いらっしゃいましたらこちらからぜひご応募いただければと思います!