ElixirでTDDに挑戦!!!

こんにちは、プロダクトチームのソーントンです。

社内の「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.SlackClientForTestMyApp.SlackClientBehaviour を実装しているものであればなんでも良いので、自前で作成したモジュールでも良いですし、モックライブラリを使って作成したものでも良いです。

Mox登場!

テスト対象の関数が依存モジュールの別の関数を意図した通りに呼んだかどうかを調べるには、モックライブラリを使うのが便利です。今回はMoxを使用して見ましょう。

mix.exs のdepsに moxを足して deps.get をした後に下記を書いてみます

# test/mock/mocks.ex

Mox.defmock(MyApp.SlackClientMock,
  for: MyApp.SlackClientBehaviour
)

MyApp.SlackClientMockMyApp.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ライフを〜

おわりに

ユーザベースではエンジニアを募集しています。ご興味ある方いらっしゃいましたらこちらからぜひご応募いただければと思います!

© Uzabase, Inc. All rights reserved.