Доктесты, паттерны и оператор with

В этой главе мы напишем код, который парсит команды, описанные в первой главе:

CREATE shopping
OK

PUT shopping milk 1
OK

PUT shopping eggs 3
OK

GET shopping milk
1
OK

DELETE shopping eggs
OK

Когда парсинг будет закончен, мы обновим сервер для отправки распарсенных команд в приложение :kv, созданное ранее.

Доктесты

В самом начале мы упомянули, что документация в Эликсире является сущностью первого класса. Мы подходили к этому с разных сторон по ходу этого руководство, будь то команда mix help, или функция h Enum, или справка по другим модулям в консоли.

В этом разделе мы реализуем парсинг с использованием доктестов, которые позволяют нам писать тесты прямо в нашей документации. Это помогает предоставлять документацию сразу с примерами кода.

Давайте создадим модуль lib/kv_server/command.ex с парсером, и начнём с доктеста к нему:

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

Доктесты задаются с помощью отступа в четыре пробела перед 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) 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: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"""
Преобразует данную строку `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}

"""

Когда доктесты написаны, ваша задача самостоятельно сделать так, чтобы они проходили! Когда закончите, можете сравнить вашу работу с нашим решением ниже:

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, которые проверяют имя команды и количество аргументов!

Наконец, вы можете увидеть, что каждый доктест воспринимается как отдельный тест, наш тестовый набор теперь сообщает о том, что всего их семь. Это происходит, потому что 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 """
  Выполняет данную команду.
  """
  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
  # Известная ошибка. Пишем клиенту.
  :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» в ином случае:

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

Это значит, что наш вариант идёт в верном направлении, но это не выглядит достаточно элегантно, не так ли?

Предыдущая реализация использовала пайплайны, которые делали логику прямолинейной. Однако, теперь нам нужно обрабатывать разные коды ошибок, а логика нашего сервера вложена во много вызовов case.

К счастью, Эликсир версии 1.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 в документации.

Запуск команд

Последний шаг – написать функцию KVServer.Command.run/1 для запуска команд после парсинга в приложении :kv. Реализация представлена ниже:

@doc """
Выполняет данную команду.
"""
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, чтобы повторно использовать общую функциональность по поиску корзины и возврату её 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

# ... другие варианты функции `run` ...

Можете попробовать продолжить, сделать изменения, описанные выше, и написать юнит-тесты. Идея в том, что наши тесты будут стартовать экземпляр 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 возвращает две строки
    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 и связанные процессы, поэтому даже нет необходимости закрывать сокет. Как только тестовый процесс закончится, сокет закроется автоматически.

Т. к. теперь наш тест основан на глобальном состоянии, мы не передаём 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 и запуском добавьте следующую строку:

@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 [info]  Application kv exited: :stopped

С этим простым интеграционным тестом мы можем увидеть, почему интеграционные тесты могут быть медленными. Не только потому, что такой тест не может работать асинхронно, он также предусматривает дорогой установку запуска и старта приложения :kv.

В конце концов, ваша задача и задача вашей команды понять, какая стратегия тестирования будет лучшей для вашего приложения. Вам нужно найти баланс между качеством кода, уверенностью в его корректной работе и временем выполнения тестов. Например, мы можем выполнять только интеграционные тесты, но если сервер продолжает расти в следующих релизах, или становится часть приложения с частыми багами, важно иметь возможность разделить его на части и написать более быстрые и дешевые юнит-тесты.

В следующей главе мы наконец сделаем нашу систему распределяемой, добавив механизм маршрутизации корзин. А также поговорим о конфигурации приложений.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.