MixとOTP 09: DocTestとwithのパターンマッチング
gumi TECH
Posted on May 28, 2019
本稿は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
ドキュメントのテキストを@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
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)
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
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}
"""
これらのドキュメントの例に応じて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
DocTest
は7つ数えられています。これはコード例ごとにテストが行われたからです。
==> kv_server
.......
Finished in 0.06 seconds
7 doctests, 0 failures
もしコード例の間に空白行を入れないと、ExUnitはひとつのテストとみなしてコンパイルします。
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
」をお読みください。
with
コマンドの解析はできました。KVServer.Command
モジュールには、実行のためのrun/1
関数を仮置きしておきます。
@doc """
与えられたコマンドを実行する。
"""
def run(_command) do
{:ok, "OK\r\n"}
end
そして、実行するロジックの実装です。lib/kv_server.ex
のKVServer
モジュールを書き替えましょう。まずは、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
つぎに、read_line/1
関数です。
defp read_line(socket) do
# {:ok, data} =
:gen_tcp.recv(socket, 0)
# data
end
そして、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
書いたコードはまだ大枠だけです。けれども、サーバーを起ち上げて、動きを確かめることはできます。
$ mix run --no-halt
00:00:00.000 [info] Accepting connections on port 4040
今のところ、コマンドと引数の数が正しければ「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
ここまでの動きは正しくできています。けれど、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
コマンドを実行する
残るは、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
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
これで、コマンドがそれぞれ実行できるようになります。
$ 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)
とします。そして、続く関数節はこれに応じて書き直すのです。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
今回は、テストがグローバルデータに依存しているので、use ExUnit.Case
にasync: true
は与えません。さらに、テストがつねに新たな状態で行われるように、テストごとに:kv
アプリケーションを終了して起動し直します。実際テストすると、:kv
アプリケーションの終了が、ターミナルに警告として加わるでしょう。
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
のあとに書き添えてください。
use ExUnit.Case
@moduletag :capture_log
そして、もしテストが失敗すると、たとえばつぎのようにレポートが示されます。
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
この統合テストは単純なのに速くありません。テストが同期的に実行されているうえ、アプリケーションをテストごとに終了して起動し直すという負荷の高い設定になっているからです。そのため、つぎのようにタイムアウトでエラーになるかもしれません。
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
の第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
最適なテスト戦略は、アプリケーションに応じて決めなければなりません。コード品質と信頼性、そしてテスト全体のランタイムなどをバランスさせなければならないのです。たとえば、はじめはサーバーだけを統合テストしてもよいでしょう。サーバーがその後のリリースで拡張し続けたり、アプリケーションの一部にたびたびバグが起こるようになると、テストの分割を考えなければなりません。統合テストのように重くないユニットテストを書いて、集中的にテストすることができます。
MixとOTPもくじ
Posted on May 28, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.