MixとOTP 09: DocTestとwithのパターンマッチング
Tags: elixir, webdev, tutorial, programming
Published: 2019-05-28 00:55:54

---
title: MixとOTP 09: DocTestとwithのパターンマッチング
published: true
description: Elixir公式サイトの許諾を得て「Doctests, patterns and with」の解説にもとづき、加筆補正を加えて、DocTestによるテストとwithを使ったパターンマッチングについてご説明します。
tags: #elixir, #webdev, #tutorial, #programming
---

本稿はElixir公式サイトの許諾を得て「[Doctests, patterns and with](https://elixir-lang.org/getting-started/mix-otp/docs-tests-and-with.html)」の解説にもとづき、加筆補正を加えて、`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>`プロンプトで、コード例が区切られます。

```elixir
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
```

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

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

```elixir
defmodule KVServer.CommandTest do
  use ExUnit.Case, async: true
  doctest KVServer.Command
end
```

<!--
`mix test`が走らせられるように、`KVServer.Application`の`start/2`関数は、ポートを指定しなくても起動できるように書き替えましょう。つぎのように、ポート番号4040はデフォルト値とします。

```elixir
# port = String.to_integer(System.get_env("PORT") || raise "missing $PORT environment variable")
port = String.to_integer(System.get_env("PORT") || "4040")
```
-->

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

```terminal
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)
```

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

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

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

```elixir
@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}

"""
```

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

```elixir
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  
```

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

```terminal
==> kv_server
.......

Finished in 0.06 seconds
7 doctests, 0 failures
```

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

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

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


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

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

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

```elixir
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
```

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

```elixir
defp read_line(socket) do
  # {:ok, data} = 
  :gen_tcp.recv(socket, 0)
  # data
end
```

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

```elixir
# 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  
```

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

```terminal
$ mix run --no-halt

00:00:00.000 [info]  Accepting connections on port 4040
```

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

```terminal
$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE shopping
OK
HELLO
UNKNOWN COMMAND
```

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

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

```elixir
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
```

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

```elixir
# 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
```

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

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

```elixir
defp write_line(socket, {:error, :not_found}) do
  :gen_tcp.send(socket, "NOT FOUND\r\n")
end  
```

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

```terminal
$ 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
```

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

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

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

> ```elixir
> 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テーブルやリンクしたプロセスのように、ソケットを閉じなくてよいことにご注目ください。テストプロセスが終了すると、ソケットは自動的に閉じるからです。

```elixir
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
```

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

```terminal
00:00:00.000 [info]  Application kv exited: :stopped
```

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

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

```elixir
use ExUnit.Case
@moduletag :capture_log
```

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

```terminal
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
```

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

```terminal
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)
```

タイムアウトを避けるには、[`:gen_tcp.recv/3`](http://erlang.org/doc/man/gen_tcp.html#recv-3)の第3引数に渡すタイムアウトのミリ秒を調整してください。

```elixir
defp send_and_recv(socket, command) do
  :ok = :gen_tcp.send(socket, command)
  {:ok, data} = :gen_tcp.recv(socket, 0, 3000)  # 1000)
  data
end
```

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


#### MixとOTPもくじ
- [MixとOTP 01: Mixを使ってみる](https://dev.to/gumi/mixotp-01-mix-dg1)
- [MixとOTP 02: Agent](https://dev.to/gumi/mixotp-02-agent-3n9g)
- [MixとOTP 03: GenServer](https://dev.to/gumi/mixotp-03-genserver-2jhn)
- [MixとOTP 04: スーパーバイザーとアプリケーション](https://dev.to/gumi/mix-otp-04-bj0)
- [MixとOTP 05: ダイナミックスーパーバイザー](https://dev.to/gumi/mix-otp-05-l3p)
- [MixとOTP 06: ETS](https://dev.to/gumi/mix-otp-06-ets-ol3)
- [MixとOTP 07: 依存関係とアンブレラプロジェクト](https://dev.to/gumi/mix-otp-07-1p5k)
- [MixとOTP 08: Taskとgen_tcp](https://dev.to/gumi/mix-otp-08-gentcp-5120)
- MixとOTP 09: DocTestとwithのパターンマッチング
- [MixとOTP 10: 分散処理のTaskと設定](https://dev.to/gumi/mix-otp-10-3i31)