Источник Тесты документации, шаблоны и 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», когда команда известна, и «НЕИЗВЕСТНАЯ КОМАНДА», в противном случае:
$ 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('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.17.2/docs-tests-and-with.html