View Source SandboxRegistry (sandbox_registry v0.1.1)

SandboxRegistry

Dialyzer Credo

We can use the sandbox registry to help create sandboxes for testing

Sandboxes help us test by allow us to specify a mock that will be utilzed only for the specific test, allowing us to modify the return value of a specific function only in test.

We can utilize this pattern by building around an adapter pattern, and using the sandbox in dev mode. Other ways to build this pattern include using a flag like sandbox? to enable sandbox mode and swap out calls to a sanbox

example-sandbox

Example Sandbox

defmodule HTTPSandbox do
  @registry :http_sandbox
  @keys :unique

  def start_link do
    Registry.start_link(keys: @keys, name: @registry)
  end

  def set_get_responses(tuples) do
    tuples
    |> Map.new(fn {url, func} -> {{:get, url}, func} end)
    |> then(&SandboxRegistry.register(@registry, @state, &1, @keys))
    |> case do
      :ok -> :ok
      {:error, :registry_not_started} -> raise_not_started!()
    end

    # Random sleep is needed to allow registry time to insert
    Process.sleep(50)
  end


  def get_response(url, headers, options) do
    func = find!(:get, url)

    func.(url, headers, options)
  end

  def find!(method, url) do
    case SandboxRegistry.lookup(@registry, @state) do
      {:ok, funcs} ->
        find_response!(funcs, method, url)

      {:error, :pid_not_registered} ->
        raise """
        No functions registered for #{inspect(self())}
        Action: #{inspect(action)}
        URL: #{inspect(url)}

        ======= Use: =======
        #{format_example(action, url)}
        === in your test ===
        """

      {:error, :registry_not_started} ->
        raise """
        Registry not started for #{inspect(__MODULE__)}.
        Please add the line:

        #{inspect(__MODULE__)}.start_link()

        to test_helper.exs for the current app.
        """
    end
  end
end

Now we can use this in an HTTP module only in test by doing

defmodule HTTP do
  @defaults_opts [
    sandbox?: Mix.env() === :test
  ]

  def get(url, header, opts) do
    opts = Keyword.merge(@defaults_opts, opts)

    if opts[:sandbox?] do
      HTTPSandbox.get_response(url, headers, opts)
    else
      make_get_request()
    end
  end
end

Now in test we have the ability to mock out our get requests per test

describe "some get request" do
  test "test get /url" do
    HTTPSandbox.set_get_responses([{
      "myurl.com",
      fn _url, _headers, _opts -> {:ok, :whatever} end
    }])

    assert {:ok, :whatever} === HTTP.get("myurl.com", [], [])
  end
end

installation

Installation

Available in Hex, the package can be installed by adding sandbox_registry to your list of dependencies in mix.exs:

def deps do
  [
    {:sandbox_registry, "~> 0.1.0"}
  ]
end

The docs can be found at https://hexdocs.pm/sandbox_registry.

Registers a map of state per test process, allowing us to build mock functionality on top

usage

Usage

  1. Start the registry in your test_helper.exs
Registry.start_link(keys: :duplicate, name: :some_registry)

:keys can be :duplicate or :unique (duplicate is faster, but you will overwrite state if setting it more than once) And the name/keys variables will need to be used in the following functions.

  1. Set the state in a setup block or within the test itself
SandboxRegistry.register(:some_registry, :my_context, %{key1: "value", key2: "other_value}, :duplicate)
  1. Access the state from anywhere in the application. SandboxRegistry functions are not available outside of test.
if Mix.env() === :test do
  def get_cache_value(key)
  :some_registry
  |> SandboxRegistry.lookup(:my_context)
  |> Map.fetch!(key)
else
  defdelegate get_cache_value(key), to: RealProductionCache
end

Link to this section Summary

Functions

Get state for a pid or any of its ancestors

Get state for a specific pid

List all pids that have registered state for context

Registers a map of state for a test process.

Link to this section Types

@type context() :: atom() | String.t()
@type keys() :: :duplicate | :unique
@type registry() :: atom()
@type result() :: {:error, :pid_not_registered | :registry_not_started} | {:ok, map()}
@type state() :: any()

Link to this section Functions

Link to this function

lookup(registry, context)

View Source
@spec lookup(registry(), context()) :: result()

Get state for a pid or any of its ancestors

Link to this function

lookup(registry, context, pid)

View Source
@spec lookup(registry(), context(), pid()) ::
  {:error, :pid_not_registered} | {:ok, state()}

Get state for a specific pid

Link to this function

lookup_pids(registry, context)

View Source
@spec lookup_pids(registry(), context()) ::
  {:ok, [pid()]} | {:error, :registry_not_started}

List all pids that have registered state for context

Link to this function

register(registry, context, state, atom)

View Source
@spec register(registry(), context(), state(), keys()) ::
  :ok | {:error, :registry_not_started}

Registers a map of state for a test process.

keys are either :duplicate or :unique and must match the value that the registry was started with, i.e.

Registry.start_link(keys: :duplicate, name: :some_registry)

The with statement handles a couple of cases: 1.) If the Pid is already registered, update the map 2.) If update_value/3 fails with error, that means that the wrong process is attempting to update. This may be because a test case with the same PID was just killed, the registry hadn't been updated yet, and the current process with the same pid (because recycling is good for the earth) tried to access that stale entry. So retry!