Bypass and async tests with ex_unit

I have a question for people who have been using bypass for testing web requests. How do you handle asynchronous tests where the code making the request is pretty far into your Phoenix context?

If I’m testing the module that makes the request directly then as an optional argument I can pass the url with the bypass port, but if the request is happening further down the call stack then I don’t want to be passing that url all the way through the application.

Do people just use mox/meck for this instead?

2 Likes

whenever I’m in a situation similar to the one you’re describing, I typically make the module that calls the external service configurable so that the external service’s url can be passed as a config parameter. Then, in the config for the test environment (test.exs), I set the url to be the bypass url.

Yes, I’ve done that too, but the problem then is because we’re setting the application env to a test-specific value, we can no longer use async: true because the application environment is global configuration.

Not sure I understand. async: true affects concurrency of test cases right? How does it affect configuration?

Sorry if this is unhelpful, but the challenges you’re facing is the reason I’m using Mox which you can use in async tests. I think to make this work with bypass you’ll end up with pretty much similar mechanism that you’d use for Mox.

@stefanchrobot can you explain to me what the challenge is? I’m curious :slight_smile:
Why can’t you use bypass in async tests? Is it because each test will spawn its own bypass process and there will be a conflict if they are all trying to bind to the same TCP port?

If I’m not mistaken, you can’t do Application.put_env (and revert it at the end) in async tests because the tests will override the values. As you mentioned, you can do it globally in config/test.exs, but the way I understood it is that @axelson doesn’t want a global config.

So it seems the options are:

  • async: false with Application.put_env,
  • async: true with global config,
  • async: true with Mox (it uses a global config to replace the real module with the mock).
3 Likes

Oh I see now. Thanks! Yes that sounds right. The question of course is "do you really need to set up your test using Application.put_env"? Personally, it’s something that I try to avoid like the plague :slight_smile:

You can look at how we tackled this in Bytepack: bytepack_archive/stripe_helpers.ex at main · dashbitco/bytepack_archive · GitHub

We defined a behaviour for the host (in this case Stripe) and then used Mox to set the Bypass URL as the host.

4 Likes

The way we handle this is by having our own config module which is just a wrapper around Application.get_env. If the env is not test, it will just call Application.get_env. If test, it will first check if there are any overrides for the given key and current process. It will also take $callers and $ancestors into account while doing the lookup. We do use Mox in many other cases, but for http api we mostly depend on bypass with the config override

Thanks! I’ll study that in more detail tomorrow.

Could you show a code snippet with example of how do you bypass with the config override? I want to try something similar, but I am not sure I am following the description

The real implementation was a bit more complicated, but this covers the essential idea (untested). The test will call MyConfig.register(app, key, url) from the before callback (url constructed from the port obtained from bypass). The application code will call MyConfig.get(app, key) and the lookup call will return an overridden value if set, otherwise, will fall back to the Application.get_env. The lookup can handle Task.spawn etc, since most of them set $callers/$ancestors.

defmodule MyConfig do
  def get(app, key) do
    if test? do
      case lookup({:__config_override, key}, [self()]) do
        nil -> Application.get_env(app, key)
        value -> value
      end
    else
      Application.get_env(app, key)
    end
  end

  def override(_app, key, value) do
    Process.put({:__config_override, key}, value)
  end

  defp lookup(key, list), do: lookup(key, list, MapSet.new())

  defp lookup(_key, [], _visited), do: nil

  defp lookup(key, [head | rest], visited) do
    if !MapSet.member?(visited, head) do
      case __get(head, key) do
        nil ->
          list = Enum.concat([rest, __get(head, :"$callers", []), __get(head, :"$ancestors", [])])
          lookup(key, list, MapSet.put(visited, head))

        value ->
          value
      end
    else
      lookup(key, rest, visited)
    end
  end

  defp __get(process, key), do: __get(process, key, nil)

  defp __get(process, key, default) when is_atom(process) do
    __get(Process.whereis(process), key, default)
  end

  defp __get(process, key, default) do
    {:dictionary, dictionary} = Process.info(process, :dictionary)
    :proplists.get_value(key, dictionary, default)
  end
end
1 Like