Spec-Zone.ru › Elixir 1.16

Исходный код Тесты документации, шаблоны и 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('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.

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

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

← Предыдущая страница Задача и gen_tcp
Следующая страница → Распределённые задачи и теги

Скачать версию ePub

Создано с помощью ExDoc (v0.32.2) для языка программирования Elixir

© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.16.3/docs-tests-and-with.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API