MixとOTP 09: DocTestとwithのパターンマッチング

gumitech

gumi TECH

Posted on May 28, 2019

MixとOTP 09: DocTestとwithのパターンマッチング

本稿はElixir公式サイトの許諾を得て「Doctests, patterns and with」の解説にもとづき、加筆補正を加えて、DocTestによるテストとwithを使ったパターンマッチングについてご説明します。

DocTest

Elixirサイトでは、ドキュメントは言語にとって重要な役割を果たす(first-class citizen)と述べています。たとえば、コマンドラインツールでmix helpと入力するとMixのコマンドが一覧できますし、IExコンソールではh Enumでモジュールの説明が見られます。

本稿では、コマンドの解析機能を実装します。そして、ドキュメントを加えて、DocTestにより最新状態であることを確かめましょう。このテストで、ドキュメントのコード例を正確に保つことができるのです。

lib/kv_server/command.exにコマンド解析の関数parse/1をつぎのように定めて、DocTestを実行してみましょう。DocTestのためにドキュメントのテキストに加えるコード例は、スペース4つのインデントにプロンプトiex>を添えて、そのあとに記述してください。コードが複数行にわたるときは、IExと同じく、...>が使えます。続く行に加えるのが期待される結果です。空白行か新たなiex>プロンプトで、コード例が区切られます。

defmodule KVServer.Command do
  @doc ~S"""
  与えられた`line`をコマンドとして解析する。

  ## 例

    iex> KVServer.Command.parse("CREATE shopping\r\n")
    {:ok, {:create, "shopping"}}

  """
  def parse(_line) do
    :not_implemented  # 未実装
  end
end
Enter fullscreen mode Exit fullscreen mode

ドキュメントのテキストを@doc ~S"""で始めました。大文字シギル~Sを用いたのは、テストのとき\r\nが改行と復帰に変換されてしまわないようにです。

DocTestを実行するファイルは、test/kv_server/command_test.exsにつくります。そして、KVServer.CommandTestモジュールからdoctest KVServer.Commandを呼び出すのです。

defmodule KVServer.CommandTest do
  use ExUnit.Case, async: true
  doctest KVServer.Command
end
Enter fullscreen mode Exit fullscreen mode

mix testを行うと、DocTestによるつぎのようなエラーが示されます。parse/1関数がドキュメントのとおりに実装されていないからです。

1) doctest KVServer.Command.parse/1 (1) (KVServer.CommandTest)
    test/kv_server/command_test.exs:3
    Doctest failed
    code:  KVServer.Command.parse("CREATE shopping\r\n") === {:ok, {:create, "shopping"}}
    left:  :not_implemented
    right: {:ok, {:create, "shopping"}}
    stacktrace:
      lib/kv_server/command.ex:7: KVServer.Command (module)
Enter fullscreen mode Exit fullscreen mode

DocTestをとおすには、parse/1関数をつぎのように実装すればよいでしょう。1行のテキストをホワイトスペースで区切ったリストに変えて、コマンドとマッチングさせています。String.split/1は、連続したホワイトスペースはひとつの区切り文字列として扱います。

# def parse(_line) do
def parse(line) do
  # :not_implemented
  case String.split(line) do
    ["CREATE", bucket] -> {:ok, {:create, bucket}}
  end
end  
Enter fullscreen mode Exit fullscreen mode

DocTestのコード例をさらに増やしましょう。新たなコマンドとエラーになる記述を、つぎのように書き加えます。

@doc ~S"""
与えられた`line`をコマンドとして解析する。

## 例

  iex> KVServer.Command.parse("CREATE shopping\r\n")
  {:ok, {:create, "shopping"}}

  iex> KVServer.Command.parse("CREATE  shopping  \r\n")
  {:ok, {:create, "shopping"}}

  iex> KVServer.Command.parse("PUT shopping milk 1\r\n")
  {:ok, {:put, "shopping", "milk", "1"}}

  iex> KVServer.Command.parse("GET shopping milk\r\n")
  {:ok, {:get, "shopping", "milk"}}

  iex> KVServer.Command.parse("DELETE shopping eggs\r\n")
  {:ok, {:delete, "shopping", "eggs"}}

定められていないコマンドや数の合わない引数はエラーになる。

  iex> KVServer.Command.parse("UNKNOWN shopping eggs\r\n")
  {:error, :unknown_command}

  iex> KVServer.Command.parse("GET shopping\r\n")
  {:error, :unknown_command}

"""
Enter fullscreen mode Exit fullscreen mode

これらのドキュメントの例に応じてparse/1関数を実装したのが、つぎのコードです。コマンド名と引数の数をマッチングさせているため、いくつもの条件判定の構文なしにすっきりとコマンドが解析できています。

def parse(line) do
  case String.split(line) do
    ["CREATE", bucket] -> {:ok, {:create, bucket}}
    ["GET", bucket, key] -> {:ok, {:get, bucket, key}}
    ["PUT", bucket, key, value] -> {:ok, {:put, bucket, key, value}}
    ["DELETE", bucket, key] -> {:ok, {:delete, bucket, key}}
    _ -> {:error, :unknown_command}
    end
end  
Enter fullscreen mode Exit fullscreen mode

DocTestは7つ数えられています。これはコード例ごとにテストが行われたからです。

==> kv_server
.......

Finished in 0.06 seconds
7 doctests, 0 failures
Enter fullscreen mode Exit fullscreen mode

もしコード例の間に空白行を入れないと、ExUnitはひとつのテストとみなしてコンパイルします。

iex> KVServer.Command.parse("UNKNOWN shopping eggs\r\n")
{:error, :unknown_command}
iex> KVServer.Command.parse("GET shopping\r\n")
{:error, :unknown_command}
Enter fullscreen mode Exit fullscreen mode

DocTestは名前が示すとおり、ドキュメントが主でテストは従の位置づけです。テストに替わる機能ではなく、ドキュメントを最新に保つことが目的とされています。DocTestについて詳しくは「ExUnit.DocTest」をお読みください。

with

コマンドの解析はできました。KVServer.Commandモジュールには、実行のためのrun/1関数を仮置きしておきます。

@doc """
与えられたコマンドを実行する。
"""
def run(_command) do
  {:ok, "OK\r\n"}
end
Enter fullscreen mode Exit fullscreen mode

そして、実行するロジックの実装です。lib/kv_server.exKVServerモジュールを書き替えましょう。まずは、serve/1関数です。case/2でコマンドの解析や実行の結果に応じて処理を進めます。

defp serve(socket) do
  # socket
  # |> read_line()
  # |> write_line(socket)
  msg =
    case read_line(socket) do
      {:ok, data} ->
        case KVServer.Command.parse(data) do
          {:ok, command} ->
            KVServer.Command.run(command)
          {:error, _} = err ->
            err
        end
      {:error, _} = err ->
        err
    end

  write_line(socket, msg)
  serve(socket)
end
Enter fullscreen mode Exit fullscreen mode

つぎに、read_line/1関数です。

defp read_line(socket) do
  # {:ok, data} = 
  :gen_tcp.recv(socket, 0)
  # data
end
Enter fullscreen mode Exit fullscreen mode

そして、write_line関数はコマンドによりやるべきことが変わります。

# defp write_line(line, socket) do
defp write_line(socket, {:ok, text}) do
  # :gen_tcp.send(socket, line)
  :gen_tcp.send(socket, text)
end

defp write_line(socket, {:error, :unknown_command}) do
  # 予め定めたエラーはクライアントに書き込む。
  :gen_tcp.send(socket, "UNKNOWN COMMAND\r\n")
end

defp write_line(_socket, {:error, :closed}) do
  # 接続が閉じたらきちんと終了する。
  exit(:shutdown)
end

defp write_line(socket, {:error, error}) do
  # 不明なエラーはクライアントに書き込んで終了する。
  :gen_tcp.send(socket, "ERROR\r\n")
  exit(error)
end  
Enter fullscreen mode Exit fullscreen mode

書いたコードはまだ大枠だけです。けれども、サーバーを起ち上げて、動きを確かめることはできます。

$ mix run --no-halt

00:00:00.000 [info]  Accepting connections on port 4040
Enter fullscreen mode Exit fullscreen mode

今のところ、コマンドと引数の数が正しければ「OK」、そうでないときは「UNKNOWN COMMAND」が示されるはずです。

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE shopping
OK
HELLO
UNKNOWN COMMAND
Enter fullscreen mode Exit fullscreen mode

ここまでの動きは正しくできています。けれど、serve/1関数がcase/2の入れ子になっていて、見やすくありません。このようなときに使えるのが、Elixir v1.2から備わったwith構文です。条件でなく、パターンマッチングにより処理が進められます。

with<-の右辺が返す式の値を、左辺のパターンとマッチングさせます。そして、合致したらつぎの式に進むのです。合致しなかったときには、その値がそのまま返されます。case/2の条件をwithの式で組み立て直したのが、つぎのコードです。{:ok, x}と合致しない式があれば、ただちにwith構文を抜けて合致しなかった値が返されます。詳しくは、ドキュメントのwith/1の項をお読みください。

defp serve(socket) do
  msg =
    # case read_line(socket) do
    #   {:ok, data} ->
    #     case KVServer.Command.parse(data) do
    #       {:ok, command} ->
    #         KVServer.Command.run(command)
    #       {:error, _} = err ->
    #         err
    #     end
    #   {:error, _} = err ->
    #     err
    # end
    with {:ok, data} <- read_line(socket),
         {:ok, command} <- KVServer.Command.parse(data),
         do: KVServer.Command.run(command)

  write_line(socket, msg)
  serve(socket)
end
Enter fullscreen mode Exit fullscreen mode

コマンドを実行する

残るは、KVServer.Commandモジュールのrun/1を実装することです。引数のコマンドにより異なるサーバーの操作を、複数節の関数で定めます。それぞれの関数節が、KV.Registryのサーバーに適切な処理を行うのです。サーバーは:kvアプリケーションが起ち上がるときに登録されます。そして、:kv_server:kvアプリケーションに依存します。したがって、アプリケーションのサービスに依存することは問題ありません。

# def run(_command) do
#   {:ok, "OK\r\n"}
# end
def run(command)

def run({:create, bucket}) do
  KV.Registry.create(KV.Registry, bucket)
  {:ok, "OK\r\n"}
end

def run({:get, bucket, key}) do
  lookup(bucket, fn pid ->
    value = KV.Bucket.get(pid, key)
    {:ok, "#{value}\r\nOK\r\n"}
  end)
end

def run({:put, bucket, key, value}) do
  lookup(bucket, fn pid ->
    KV.Bucket.put(pid, key, value)
    {:ok, "OK\r\n"}
  end)
end

def run({:delete, bucket, key}) do
  lookup(bucket, fn pid ->
    KV.Bucket.delete(pid, key)
    {:ok, "OK\r\n"}
  end)
end

defp lookup(bucket, callback) do
  case KV.Registry.lookup(KV.Registry, bucket) do
    {:ok, pid} -> callback.(pid)
    :error -> {:error, :not_found}
  end
end
Enter fullscreen mode Exit fullscreen mode

run/1関数はdef run(command)だけで、本体がありません。本体のないヘッダだけの関数には、デフォルト引数が宣言できます(「Elixir入門 08: モジュールと関数」「デフォルト引数」参照)。けれど、ここではデフォルト値は与えませんでした。引数を明らかにするために用いているのです。

前掲KVServer.Commandモジュールには、共通に用いられるプライベートなlookup/2を加えました。目的のプロセスを探して、見つかればそのpid、なければ{:error, :not_found}を返す関数です。すると、KVServerモジュールのwrite_line/2にも、対応した関数節をwrite_line(socket, {:error, error})の手前に加えなければなりません。

defp write_line(socket, {:error, :not_found}) do
  :gen_tcp.send(socket, "NOT FOUND\r\n")
end  
Enter fullscreen mode Exit fullscreen mode

これで、コマンドがそれぞれ実行できるようになります。

$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE shopping
OK
PUT shopping milk 1
OK
PUT shopping eggs 3
OK
GET shopping milk
1
OK
DELETE shopping eggs
OK
Enter fullscreen mode Exit fullscreen mode

サーバーの機能はほぼでき上がりました。まだ手をつけていないのがテストです。テストをどのように行うかは考えなければなりません。

KVServer.Command.run/1は、コマンドをKV.Registryという名前のサーバーに直に送るように実装しました。サーバーは:kvアプリケーションが登録します。つまり、サーバーがグローバルになるということです。すると、もしふたつのテストが同時にメッセージを送ったら、テストは互いに競合します(失敗するかもしれません)。そこで決めなければならないのは、ユニットテストにして同時実行できるように切り離すか、グローバルな状態の上で動作する統合テストを書くかということです。ここでは、アプリケーションのフルスタックを実行して、本番システムのテストにしましょう。

KVServer.Command.run/1を単体でテストできるようにするには、コマンドを直にKV.Registryのプロセスに送るのでなく、サーバーは引数として渡すように実装しなければなりません。たとえば、関数のヘッダをdef run(command, pid)とします。そして、続く関数節はこれに応じて書き直すのです。

def run({:create, bucket}, pid) do
  KV.Registry.create(pid, bucket)
  {:ok, "OK\r\n"}
end
# 他の関数節も同様。

このように書き替えた場合、KV.Registryのインスタンスをつくって、run/2の引数として渡すことになります。グローバルなKV.Registryに依存しませんので、状態を共有することなくテストは非同期にできるのです。

統合テストは、グローバルなサーバー名に依存して、TCPサーバーから子プロセスまでスタック全体を実行します。テストがグローバルな状態に依存するので、同期していなければなりません。統合テストでは、アプリケーションのコンポーネントがどのように連携しているかわかる反面、テストのパフォーマンスに影響します。通常使われる場面は、アプリケーションのおもなフローのテストです。たとえば今回のコマンド解析で、細かな条件に応じた実装を確かめるのには向いていません。

統合テストの実装はtest/kv_server_test.exsに以下のとおりです。TCPクライアントを用いてサーバーにコマンドを送り、意図した応答が得られたかどうか調べます。統合テストが確かめるのは、知らないコマンドや見つからないエラーも含め、サーバーとのすべてのやり取りです。そして、ETSテーブルやリンクしたプロセスのように、ソケットを閉じなくてよいことにご注目ください。テストプロセスが終了すると、ソケットは自動的に閉じるからです。

defmodule KVServerTest do
  use ExUnit.Case

  setup do
    Application.stop(:kv)
    :ok = Application.start(:kv)
  end

  setup do
    opts = [:binary, packet: :line, active: false]
    {:ok, socket} = :gen_tcp.connect('localhost', 4040, opts)
    %{socket: socket}
  end

  test "server interaction", %{socket: socket} do
    assert send_and_recv(socket, "UNKNOWN shopping\r\n") ==
           "UNKNOWN COMMAND\r\n"

    assert send_and_recv(socket, "GET shopping eggs\r\n") ==
           "NOT FOUND\r\n"

    assert send_and_recv(socket, "CREATE shopping\r\n") ==
           "OK\r\n"

    assert send_and_recv(socket, "PUT shopping eggs 3\r\n") ==
           "OK\r\n"

    # GET returns two lines
    assert send_and_recv(socket, "GET shopping eggs\r\n") == "3\r\n"
    assert send_and_recv(socket, "") == "OK\r\n"

    assert send_and_recv(socket, "DELETE shopping eggs\r\n") ==
           "OK\r\n"

    # GET returns two lines
    assert send_and_recv(socket, "GET shopping eggs\r\n") == "\r\n"
    assert send_and_recv(socket, "") == "OK\r\n"
  end

  defp send_and_recv(socket, command) do
    :ok = :gen_tcp.send(socket, command)
    {:ok, data} = :gen_tcp.recv(socket, 0, 1000)
    data
  end
end
Enter fullscreen mode Exit fullscreen mode

今回は、テストがグローバルデータに依存しているので、use ExUnit.Caseasync: trueは与えません。さらに、テストがつねに新たな状態で行われるように、テストごとに:kvアプリケーションを終了して起動し直します。実際テストすると、:kvアプリケーションの終了が、ターミナルに警告として加わるでしょう。

00:00:00.000 [info]  Application kv exited: :stopped
Enter fullscreen mode Exit fullscreen mode

テストのときログメッセージが示されないようにするために、ExUnitに備わるのが:capture_logの機能です。テストごとに@tag :capture_logをテストの前に加えるか、テストモジュール全体に対して@moduletag :capture_logを定めると、ExUnitはテストが実行されている間のログを自動的にキャプチャします。そして、テストが失敗したときに、キャプチャされたログがExUnitレポートに加えて出力されるのです。

@moduletag :capture_logは、use ExUnit.Caseのあとに書き添えてください。

use ExUnit.Case
@moduletag :capture_log
Enter fullscreen mode Exit fullscreen mode

そして、もしテストが失敗すると、たとえばつぎのようにレポートが示されます。

1) test server interaction (KVServerTest)
    test/kv_server_test.exs:17
    ** (RuntimeError) oops
    stacktrace:
      test/kv_server_test.exs:29

    The following output was logged:

    00:00:00.000 [info]  Application kv exited: :stopped
Enter fullscreen mode Exit fullscreen mode

この統合テストは単純なのに速くありません。テストが同期的に実行されているうえ、アプリケーションをテストごとに終了して起動し直すという負荷の高い設定になっているからです。そのため、つぎのようにタイムアウトでエラーになるかもしれません。

1) test server interaction (KVServerTest)
    test/kv_server_test.exs:16
    ** (MatchError) no match of right hand side value: {:error, :timeout}
    code: assert send_and_recv(socket, "DELETE shopping eggs\r\n") ==
    stacktrace:
      test/kv_server_test.exs:43: KVServerTest.send_and_recv/2
      test/kv_server_test.exs:33: (test)
Enter fullscreen mode Exit fullscreen mode

タイムアウトを避けるには、:gen_tcp.recv/3の第3引数に渡すタイムアウトのミリ秒を調整してください。

defp send_and_recv(socket, command) do
  :ok = :gen_tcp.send(socket, command)
  {:ok, data} = :gen_tcp.recv(socket, 0, 3000)  # 1000)
  data
end
Enter fullscreen mode Exit fullscreen mode

最適なテスト戦略は、アプリケーションに応じて決めなければなりません。コード品質と信頼性、そしてテスト全体のランタイムなどをバランスさせなければならないのです。たとえば、はじめはサーバーだけを統合テストしてもよいでしょう。サーバーがその後のリリースで拡張し続けたり、アプリケーションの一部にたびたびバグが起こるようになると、テストの分割を考えなければなりません。統合テストのように重くないユニットテストを書いて、集中的にテストすることができます。

MixとOTPもくじ

💖 💪 🙅 🚩
gumitech
gumi TECH

Posted on May 28, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related