Skip to content

alanvardy/sandbox_registry

Repository files navigation

SandboxRegistry

Dialyzer Credo Build Status Build Status Build Status

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 utilized 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 sandbox

Example Sandbox

defmodule HTTPSandbox do
 @moduledoc """
  For mocking out HTTP GET requests in test.

  Stores a map of functions in a Registry under the PID of the test case when
  `set_get_responses/1` is called.

  """
  @registry :http_sandbox
  @keys :unique
  # context is a sub-key to allow multiple contexts to use the same registry
  @context "http"
  @sleep 10  

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


  @doc """
  Can be called in HTTP client module in test environment instead of get request to external API
  """
  def get_response(url, headers, options) do
    func = find!(:get, url)

    case :erlang.fun_info(func)[:arity] do
      0 -> func.()
      3 -> func.(url, headers, options)
    end
  end


  @doc """
  Set sandbox responses in test. Call this function in your setup block with a list of tuples.

  The tuples have two elements:
  - The first element is either a string url or a regex that needs to match on the url
  - The second element is a 0 or 3 arity anonymous function. The arguments for the 3 arity
  are url, headers, options.


  
  `HTTPSandbox.set_get_responses([{"http://google.com/", fn ->
    {:ok, {"I am a response", %{status: 200}}}
  end}])`

  the url headers and opts can be pattern matched here to assert the correct request was sent.
  `HTTPSandbox.set_get_responses([
    {"http://google.com/", fn url, headers, opts ->
      {:ok, {"I am a response", %{status: 200}}}
    end}])` 

  """

  def set_get_responses(tuples) do
    tuples
    |> Map.new(fn {url, func} -> {{:get, url}, func} end)
    |> then(&SandboxRegistry.register(@registry, @context, &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(@sleep)
  end
  
   @doc """
  Finds out whether its PID or one of its ancestor's PIDs have been registered
  Returns response function or raises an error for developer
  """
  def find!(action, url) do
    case SandboxRegistry.lookup(@registry, @context) do
      {:ok, funcs} ->
        find_response!(funcs, action, 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

  defp find_response!(funcs, action, url) do
    key = {action, url}

    with funcs when is_map(funcs) <- Map.get(funcs, key, funcs),
         regexes <- Enum.filter(funcs, fn {{_, k}, _v} -> Regex.regex?(k) end),
         {_regex, func} when is_function(func) <-
           Enum.find(regexes, funcs, fn {{_, k}, _v} -> Regex.match?(k, url) end) do
      func
    else
      func when is_function(func) ->
        func

      functions when is_map(functions) ->
        functions_text =
          Enum.map_join(functions, "\n", fn {k, v} -> "#{inspect(k)}    =>    #{inspect(v)}" end)

        raise """
        Response not found in registry for {action, url} in #{inspect(self())}
        Found in registry:
        #{functions_text}

        ======== Add this: ========
        #{format_example(action, url)}
        === in your test setup ====
        """

      other ->
        raise """
        Unrecognized input for {action, url} in #{inspect(self())}

        Did you use
        fn -> function() end
        in your set_get_responses/1 ?

        Found:
        #{inspect(other)}

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

  defp format_example(action, url) do
    """
    setup do
      HTTPSandbox.set_#{action}_responses([
        {#{inspect(url)}, fn _url, _headers, _options -> _response end},
        # or
        {#{inspect(url)}, fn -> _response end}
        # or
        {~r|http://na1|, fn -> _response end}
      ])
    end
    """
  end

  defp raise_not_started! do
    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

For our tests, we need to add HTTPSandbox.start_link() to our test_helpers.exs file. Now 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

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.1"}
  ]
end

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

Note for contributors

This project utilizes ex_check. For your convenience, you can run all checking tools with a single mix check command.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •  

Languages