Исходный код Тесты документации, шаблоны и with
В этой главе мы реализуем код, который анализирует команды, описанные в первой главе:
CREATE shopping OK PUT shopping milk 1 OK PUT shopping eggs 3 OK GET shopping milk 1 OK DELETE shopping eggs OK
После завершения анализа мы обновим наш сервер, чтобы он распределял проанализированные команды по приложению :kv , которое мы создали ранее.
Тесты документации
На домашней странице языка мы упомянули, что в Elixir документация является первоклассным элементом языка. Мы много раз исследовали эту концепцию в этом руководстве, будь то через mix help или набирая h Enum или другое модуль в консоли IEx.
В этом разделе мы реализуем функциональность анализа, документируем её и убедимся, что наша документация обновлена с тестами документации. Это помогает нам предоставлять документацию с точными примерами кода.
Давайте создадим наш анализатор команд в lib/kv_server/command.ex и начнём с теста документации:
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
Тесты документации задаются отступом в четыре пробела, за которым следует запрос iex> в строке документации. Если команда занимает несколько строк, вы можете использовать ...>, как в IEx. Ожидаемый результат должен начинаться с следующей строки после iex> или ...> строки(ок) и заканчивается либо новой строкой, либо новым префиксом iex>.
Также обратите внимание, что мы начали строку документации с @doc ~S""". ~S предотвращает преобразование символов \r\n в возврат каретки и перевод строки до тех пор, пока они не будут оценены в тесте.
Для запуска наших тестов документации мы создадим файл в test/kv_server/command_test.exs и вызовем doctest KVServer.Command в тестовом случае:
defmodule KVServer.CommandTest do use ExUnit.Case, async: true doctest KVServer.Command end
Запустите набор тестов, и тест документации должен завершиться неудачей:
1) doctest KVServer.Command.parse/1 (1) (KVServer.CommandTest)
test/kv_server/command_test.exs:3
Doctest failed
doctest:
iex> KVServer.Command.parse("CREATE shopping\r\n")
{:ok, {:create, "shopping"}}
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)
Отлично!
Теперь давайте сделаем тест документации успешным. Давайте реализуем функцию parse/1.
def parse(line) do
case String.split(line) do
["CREATE", bucket] -> {:ok, {:create, bucket}}
end
end
Наша реализация разделяет строку по пробелам, а затем сопоставляет команду со списком. Использование String.split/1 означает, что наши команды будут игнорировать пробелы. Ведущие и хвостовые пробелы не будут иметь значения, так же как и последовательные пробелы между словами. Добавьте несколько новых тестов документации, чтобы проверить это поведение наряду с другими командами:
@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}
"""
Имея на руках тесты документации, пришло ваше время сделать тесты успешными! Когда вы будете готовы, вы можете сравнить свою работу с нашим решением ниже:
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 условий, проверяющих имя команды и количество аргументов!
Наконец, вы, возможно, заметили, что каждый тест документации соответствует разному тесту в нашем наборе, который теперь сообщает о 7 тестах документации в общей сложности. Это потому, что ExUnit рассматривает следующее как определение двух разных тестов документации:
iex> KVServer.Command.parse("UNKNOWN shopping eggs\r\n")
{:error, :unknown_command}
iex> KVServer.Command.parse("GET shopping\r\n")
{:error, :unknown_command}
Без новых строк, как показано ниже, ExUnit компилирует его в один тест документации:
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.
with
Поскольку мы теперь можем анализировать команды, мы наконец можем начать реализовывать логику, которая выполняет эти команды. Давайте добавим предварительное определение для этой функции пока:
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 и замените существующее определение сервера:
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
следующим:
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
# Known error; write to the client
:gen_tcp.send(socket, "UNKNOWN COMMAND\r\n")
end
defp write_line(_socket, {:error, :closed}) do
# The connection was closed, exit politely
exit(:shutdown)
end
defp write_line(socket, {:error, error}) do
# Unknown error; write to the client and exit
:gen_tcp.send(socket, "ERROR\r\n")
exit(error)
end
Если мы запустим наш сервер, мы теперь сможем отправлять ему команды. Пока мы будем получать два разных ответа: «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
Это означает, что наша реализация идёт в правильном направлении, но она не выглядит очень элегантно, не так ли?
Предыдущая реализация использовала конвейеры, которые делали логику простой для понимания. Однако теперь, когда нам нужно обрабатывать различные коды ошибок по пути, логика нашего сервера вложена во множество case вызовов.
К счастью, в Elixir v1.2 была введена конструкция with, которая позволяет упростить код, подобный вышеприведённому, заменяя вложенные case вызовы цепочкой сопоставляющих условий. Давайте перепишем функцию serve/1 для использования with.
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/1 в нашей документации.
Выполнение команд
Последний шаг — реализовать KVServer.Command.run/1, чтобы запустить проанализированные команды против приложения :kv. Его реализация показана ниже:
@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, вполне допустимо зависеть от предоставляемых им сервисов.
Вы, возможно, заметили, что у нас есть заголовок функции def run(command) без тела. В главе «Модули и функции» мы узнали, что функция без тела может использоваться для объявления аргументов по умолчанию для функции с несколькими правилами. Вот ещё один пример, где мы используем функцию без тела, чтобы документировать, какими аргументами она обладает.
Обратите внимание, что мы также определили частную функцию с именем lookup/2 для помощи в общей функциональности поиска ведра и возврата его pid , если оно существует, {:error, :not_found} в противном случае.
Кстати, так как теперь мы возвращаем {:error, :not_found}, мы должны изменить функцию write_line/2 в KVServer для печати подобной ошибки тоже:
defp write_line(socket, {:error, :not_found}) do
:gen_tcp.send(socket, "NOT FOUND\r\n")
end
Функциональность нашего сервера практически завершена. Не хватает только тестов. На этот раз мы оставили тесты на потом, потому что есть некоторые важные моменты, которые следует учесть.
Реализация KVServer.Command.run/1 отправляет команды напрямую на сервер с именем KV.Registry, который зарегистрирован приложением :kv. Это означает, что сервер является глобальным, и если у нас есть два теста, отправляющих сообщения на него одновременно, наши тесты будут конфликтовать друг с другом (и, вероятно, завершатся неудачей). Нам нужно выбрать между написанием автономных и асинхронных модульных тестов или написанием интеграционных тестов, работающих с глобальным состоянием, но проверяющих весь стек нашего приложения, как оно должно работать в рабочей среде.
До сих пор мы писали только модульные тесты, обычно тестирующие отдельный модуль напрямую. Однако, чтобы сделать KVServer.Command.run/1 тестируемым как отдельный модуль, нам нужно изменить его реализацию, чтобы он не отправлял команды напрямую в процесс KV.Registry, а вместо этого передавал сервер как аргумент. Например, нам нужно изменить сигнатуру run на def run(command, pid) и соответственно изменить все правила:
def run({:create, bucket}, pid) do
KV.Registry.create(pid, bucket)
{:ok, "OK\r\n"}
end
# ... other run clauses ...
Не стесняйтесь внести указанные выше изменения и написать несколько модульных тестов. Идея в том, что ваши тесты будут запускать экземпляр KV.Registry и передавать его как аргумент в run/2 вместо полагания на глобальный KV.Registry. Это даёт преимущество в сохранении асинхронности наших тестов, так как нет общего состояния.
Но давайте попробуем что-то другое. Давайте напишем интеграционные тесты, которые опираются на имена глобальных серверов, чтобы проверить весь стек от TCP-сервера до ведра. Наши интеграционные тесты будут опираться на глобальное состояние и должны быть синхронными. С интеграционными тестами мы получаем покрытие по взаимодействию компонентов нашего приложения, в ущерб производительности тестов. Они обычно используются для проверки основных потоков в вашем приложении. Например, нам следует избегать использования интеграционных тестов для проверки граничного случая в нашей реализации анализа команд.
Наш интеграционный тест будет использовать TCP-клиента, отправляющего команды на наш сервер, и проверяет получение желаемых ответов.
Давайте реализуем интеграционный тест в test/kv_server_test.exs как показано ниже:
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(~c"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
Наш интеграционный тест проверяет все взаимодействия с сервером, включая неизвестные команды и ошибки «не найдено». Стоит отметить, что, как и в случае с таблицами ETS и связанными процессами, нет необходимости закрывать сокет. После завершения процесса теста сокет автоматически закрывается.
На этот раз, так как наш тест зависит от глобальных данных, мы не передаём async: true в use ExUnit.Case. Кроме того, для обеспечения того, чтобы наш тест всегда находился в чистом состоянии, мы останавливаем и запускаем приложение :kv перед каждым тестом. На самом деле, остановка приложения :kv даже выводит предупреждение в терминале:
18:12:10.698 [info] Application kv exited: :stopped
Чтобы избежать вывода сообщений в журналах во время тестов, ExUnit предоставляет удобную функцию под названием :capture_log. Установив @tag :capture_log перед каждым тестом или @moduletag :capture_log для всего модуля тестов, ExUnit автоматически захватывает всё, что записывается в журнал во время выполнения теста. В случае неудачи теста захваченные журналы будут выведены вместе с отчётом ExUnit.
Между use ExUnit.Case и setup добавьте следующий вызов:
@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:
13:44:10.035 [notice] Application kv exited: :stopped
С этим простым интеграционным тестом мы начинаем понимать, почему интеграционные тесты могут быть медленными. Этот тест не только не может выполняться асинхронно, но также требует дорогостоящей настройки остановки и запуска приложения :kv.
В конечном счёте, вам и вашей команде предстоит разработать наилучшую стратегию тестирования для ваших приложений. Вам необходимо сбалансировать качество кода, уверенность и время выполнения набора тестов. Например, мы можем начать с тестирования сервера только с помощью интеграционных тестов, но если сервер продолжает расти в будущих релизах или становится частью приложения с частыми ошибками, важно рассмотреть возможность его разделения и написания более интенсивных модульных тестов, которые не несут бремени интеграционного теста.
Перейдём к следующей главе. Мы наконец-то сделаем нашу систему распределённой, добавив механизм маршрутизации по корзинам. Мы воспользуемся этой возможностью, чтобы также улучшить наши навыки тестирования.
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.18.1/docs-tests-and-with.html