Mix 和 OTP-Docs、tests 和 with

  1. Doctests
  2. with
  3. 运行命令

本章是Mix和OTP教程的一部分,它依赖这个教程的前面章节。要获得更多信息,请阅读本教程的第一章,或者查看本教程的章节索引。

在本章,我们将实现代码来分析我们在第一章描述的命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE shopping
OK
PUT shopping milk 1
OK
PUT shopping eggs 3
OK
GET shopping milk
1
OK
DELETE shopping eggs
OK

分析做完后,我们将修改我们的服务器来派发被分析的命令给我们前面已经构建的 :kv 应用。

Doctests

在Elixir语言首页,我们提到,Elixir使得文档在语言里作为第一类公民。我们已经在本指南探索这个概念多次,这些探索都是通过 mix help 或在IEx控制台上输入 h Enum 或 h 其他模块 来做到的。

在本节,我们将用 doctests 实现分析功能,它让我们直接在注释文档里写测试用例。这帮助我们提供有精确样例代码的注释文档。

让我们在 lib/kv_server/command.ex 创建我们的命令分析器,并用 doctest 启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
defmodule KVServer.Command do
@doc ~S"""
Parses the given `line` into a command.
## Examples
iex> KVServer.Command.parse "CREATE shopping\r\n"
{:ok, {:create, "shopping"}}
"""
def parse(line) do
:not_implemented
end
end

Doctest通过在注释文档字符串里的缩进四个空格在紧跟 iex> 提示符来指定。如果一个命令跨多行,你可以使用 …> 作为在IEx里。期望的结果在 iex> 或 …> 行的下一行,用一个新行或新的 iex> 前缀作为结束。

也要注意:我们用 @doc ~S””” 开始注释文档字符串。~S 阻止 \r\n 被转换为回车和换行,直到它们在测试里被计算。

为了运行我们的doctest,我们创建一个文件:test/kv_server/command_test.exs ,并且在测试用例里调用 doctest KVServer.Command :

1
2
3
4
defmodule KVServer.CommandTest do
use ExUnit.Case, async: true
doctest KVServer.Command
end

运行这个测试,doctest将失败:

1
2
3
4
5
6
7
1) test doc at 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"}}
lhs: :not_implemented
stacktrace:
lib/kv_server/command.ex:11: KVServer.Command (module)

不错!

现在让我们使得 doctest 通过。让我们来实现 parse/1 函数:

1
2
3
4
5
def parse(line) do
case String.split(line) do
["CREATE", bucket] -> {:ok, {:create, bucket}}
end
end

我们的实现用空格分割一行字符串,然后把命令和一个列表匹配起来。使用 String.split/1 意味着我们的命令将是空格不敏感的。前导和尾随空格无关紧要了,词与词之间的连续的空格也无关紧要。让我们加入一些新的doctest,用其他命令来测试这个行为模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@doc ~S"""
Parses the given `line` into a command.
## Examples
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"}}
Unknown commands or commands with the wrong number of
arguments return an error:
iex> KVServer.Command.parse "UNKNOWN shopping eggs\r\n"
{:error, :unknown_command}
iex> KVServer.Command.parse "GET shopping\r\n"
{:error, :unknown_command}
"""

有了doctest,轮到你来使得测试通过了!一旦你准备好了,你可以比较你的实现和我们下面的解决方案:

1
2
3
4
5
6
7
8
9
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

注意:我们是如何能够优雅地分析命令,而不需要用大量的 if/else 分支来检查命令名字和参数个数!

最后,你可能已经观察到每一个doctest被认为是我们测试用例里的不同测试,因为我们的测试套件报告一共有7个测试。这是因为ExUnit认为如下所示是定义两个不同测试:

1
2
3
4
5
iex> KVServer.Command.parse "UNKNOWN shopping eggs\r\n"
{:error, :unknown_command}
iex> KVServer.Command.parse "GET shopping\r\n"
{:error, :unknown_command}

而如下所示,中间没有新行,则ExUnit则编译它为一个单独的测试:

1
2
3
4
iex> KVServer.Command.parse "UNKNOWN shopping eggs\r\n"
{:error, :unknown_command}
iex> KVServer.Command.parse "GET shopping\r\n"
{:error, :unknown_command}

你可以在 ExUnit.DocTest 文档里阅读更多关于doctest的内容。

with

因为我们现在能够分析命令,我们终于可以开始实现逻辑来运行命令。现在让我们为这个函数添加一个存根(stub)定义:

1
2
3
4
5
6
7
8
defmodule KVServer.Command do
@doc """
Runs the given command.
"""
def run(command) do
{:ok, "OK\r\n"}
end
end

在我们实现这个函数之前,让我们修改我们的服务器开始使用我们新的 parse/1 和 run/1 函数。记住:当客户端关闭套接字的时候,我们的 read_line/1 函数也崩溃,所以我们也找个机会修复它。打开 lib/kv_server.ex ,其内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
defp serve(socket) do
socket
|> read_line()
|> write_line(socket)
serve(socket)
end
defp read_line(socket) do
{:ok, data} = :gen_tcp.recv(socket, 0)
data
end
defp write_line(line, socket) do
:gen_tcp.send(socket, line)
end

我们用如下内容替换它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
write_line(socket, msg)
serve(socket)
end
defp read_line(socket) do
:gen_tcp.recv(socket, 0)
end
defp write_line(socket, {:ok, text}) do
: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

如果我们启动我们的服务器,我们现在可以发送命令给它。现在我们将得到两种不同的响应:当命令是已知的,是“OK”;否则,是“UNKNOWN COMMAND”:

1
2
3
4
5
6
7
8
$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE shopping
OK
HELLO
UNKNOWN COMMAND

这意味着我们的实现在正确的方向上进行,不过它看起来不是很优雅,是吗?

以前的实现使用管道,使逻辑直截了当地前进。然而,现在我们需要处理不同的错误代码在前进的道路上,我们的服务器逻辑嵌套在许多case调用里。

幸运地是,Elixir v1.2 引入 with 结构,它允许你简化像上面的代码,有一个匹配分支链来替换嵌套的call调用。让我们用with重写serve/1:

1
2
3
4
5
6
7
8
9
defp serve(socket) do
msg =
with {:ok, data} <- read_line(socket),
{:ok, command} <- KVServer.Command.parse(data),
do: KVServer.Command.run(command)
write_line(socket, msg)
serve(socket)
end

好多了!with将获取 <- 右边返回的值与 <- 左边的模式进行匹配。如果值匹配上模式,则with继续移动到下一个表达式。如果没有匹配,则返回非匹配值。

也就是说,我们转换每一个给予 case/2 的表达式作为 with 里的一步。只要任何一步的返回值不能匹配 {:ok, x} ,则with终止,并且返回非匹配值。

你可以在我们的文档里读到更多关于with的内容。

运行命令

最后一步是实现 KVServer.Command.run/1 ,来运行分析好的对应于 :kv 应用的命令。它的实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@doc """
Runs the given command.
"""
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

每一个函数分支派发合适的命令到 KV.Registry 服务器,这个服务器我们在 :kv 应用启动的时候注册了。由于我们的 :kv_server 取决于 :kv 应用,它是完全可以依靠它提供的服务。

注意:我们也提供了一个名为 lookup/2 的私有函数,它帮助查找一个bucket,如果此bucket存在则返回其pid,否则,返回 {:error, :not_found} 。

顺便说一句,既然我们现在将返回 {:error, :not_found} ,我们就应该修改 KVServer里的 write_line/2 函数,让它也打印这样的错误:

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

我们的服务器功能几乎完成了。这剩下测试没完成。这次,我们最后剩下测试因为有一些重要考虑要做。

KVServer.Command.run/1的实现是直接发送命令给被 :kv 应用注册的名为 KV.Registry 的服务器。这意味着这个服务器是全局的,如果我们有两个测试用例同时发送消息给它,我们的测试用例将彼此冲突(并且可能失败)。我们需要在隔离和可以异步运行的单元测试之间做出决定,或者编写在全局状态之上工作的集成测试,但是在应用程序中执行我们的应用程序的完整堆栈,因为它意味着要在生产中进行操作。

到目前为止,我们只编写了单元测试,通常直接测试单个模块。然而,为了使kvserver.command.run/1作为一个可测试单元,我们需要改变它的实现,不能直接将命令发送到 KV.Registry 进程而是通过传递一个服务器作为参数。例如,我们将修改 run 函数的签名为 def run(command, pid) ,然后响应地修改所有分支:

1
2
3
4
5
6
def run({:create, bucket}, pid) do
KV.Registry.create(pid, bucket)
{:ok, "OK\r\n"}
end
# ... 其他 run 函数分支 ...

去做上面的修改,写一些单元测试。目的是你的测试用例将启动一个 KV.Registry 实例并且把它作为参数传递给 run/2 而不是依赖全局 KV.Registry 。这有利于保持我们的测试异步,因为没有共享状态。

由于这是我们迄今为止在测试中所做的方法,我们将尝试一些不同的方法。让我们来写集成测试,它依赖全局服务器名来运用从TCP服务器到bucket的整个栈。我们的集成测试将依赖全局状态并且必须是同步的。通过集成测试,我们得到了覆盖在我们应用中组件如何一起工作的测试性能的成本。它们通常用于测试应用程序中的主要流程。例如,我们应该避免使用集成测试来测试在我们的命令解析实现的边缘情况。

我们的集成测试将用一个TCP客户端发送命令给我们的服务器,然后断言我们将得到想要的响应。

让我们在 test/kv_server_test.exs 里实现集成测试,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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)
{:ok, 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 返回两行
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 返回两行
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

我们的集成测试检查所有服务器交互,包括未知命令和not found错误。值得注意的是,因为有ETS表和被链接进程,没有必要关闭套接字。一旦测试进程退出,套接字自动关闭。

这次因为我们的测试依赖全局数据,所有我们没有给 ExUnit.Case 设置 async: true 。另外,为了保证我们的测试总是在一个干净的状态中,在每一个测试用例前我们停止然后启动 :kv 应用。实际上,停止 :kv 应用甚至在终端打印一条警告信息:

1
18:12:10.698 [info] Application kv exited: :stopped

为了避免在测试期间打印日志消息,ExUnit提供了一个方便的特性,叫做 :capture_log 。通过在每一个测试用例前设置 @tag :capture_log 或为整个测试设置 @moduletag :capture_log ,ExUnit 将捕获在测试运行的时候记录的任何日志。如果我们的测试失败,被捕获的日志将和ExUnit报告一起被打印出来。

在 use ExUnit.Case 和 setup 之间增加如下调用:

1
@moduletag :capture_log

如果测试崩溃,你将看到如下的报告:

1
2
3
4
5
6
7
8
9
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:
13:44:10.035 [info] Application kv exited: :stopped

通过这个简单的集成测试,我们开始明白为什么集成测试可能是缓慢的。 这个集成测试不仅不能异步运行,它还需要停止和启动 :kv 应用这样的耗时步骤。

最后,由你和你的团队为你们的应用制定出最好的测试策略。你需要平衡代码质量、信心和测试套件运行时。例如,我们可能一开始只有集成测试服务器的测试,但是如果服务器继续在将来的版本中成长,或它成为频繁的错误应用的一部分,重要的是要考虑分解集成测试,写更多的单元测试,无须集成测试这么重。

在下一章,我们通过增加一个bucket路由机制最终使得我们的系统成为分布式。我们也将学习应用配置。

原文链接: http://elixir-lang.org/getting-started/mix-otp/docs-tests-and-with.html