defmodule Req.Test do @moduledoc """ Req testing conveniences. Req is composed of: * `Req` - the high-level API * `Req.Request` - the low-level API and the request struct * `Req.Steps` - the collection of built-in steps * `Req.Test` - the testing conveniences (you're here!) Req already has built-in support for different variants of stubs via `:plug`, `:adapter`, and (indirectly) `:base_url` options. With this module you can: * Create request stubs using [`Req.Test.stub(name, plug)`](`stub/2`) and mocks using [`Req.Test.expect(name, count, plug)`](`expect/3`). Both can be used in concurrent tests. * Configure Req to run requests through mocks/stubs by setting `plug: {Req.Test, name}`. This works because `Req.Test` itself is a plug whose job is to fetch the mocks/stubs under `name`. * Easily create JSON responses with [`Req.Test.json(conn, body)`](`json/2`), HTML responses with [`Req.Test.html(conn, body)`](`html/2`), and text responses with [`Req.Test.text(conn, body)`](`text/2`). * Simulate network errors with [`Req.Test.transport_error(conn, reason)`](`transport_error/2`). Mocks and stubs are using the same ownership model of [nimble_ownership](https://hex.pm/packages/nimble_ownership), also used by [Mox](https://hex.pm/packages/mox). This allows `Req.Test` to be used in concurrent tests. ## Example Imagine we're building an app that displays weather for a given location using an HTTP weather service: defmodule MyApp.Weather do def get_rating(location) do case get_temperature(location) do {:ok, %{status: 200, body: %{"celsius" => celsius}}} -> cond do celsius < 18.0 -> {:ok, :too_cold} celsius < 30.0 -> {:ok, :nice} true -> {:ok, :too_hot} end _ -> :error end end def get_temperature(location) do [ base_url: "https://weather-service", params: [location: location] ] |> Keyword.merge(Application.get_env(:myapp, :weather_req_options, [])) |> Req.request() end end We configure it for production: # config/runtime.exs config :myapp, weather_req_options: [ auth: {:bearer, System.fetch_env!("MYAPP_WEATHER_API_KEY")} ] In tests, instead of hitting the network, we make the request against a [plug](`Req.Steps.run_plug/1`) _stub_ named `MyApp.Weather`: # config/test.exs config :myapp, weather_req_options: [ plug: {Req.Test, MyApp.Weather} ] Now we can control our stubs **in concurrent tests**: use ExUnit.Case, async: true test "nice weather" do Req.Test.stub(MyApp.Weather, fn conn -> Req.Test.json(conn, %{"celsius" => 25.0}) end) assert MyApp.Weather.get_rating("Krakow, Poland") == {:ok, :nice} end ## Concurrency and Allowances The example above works in concurrent tests because `MyApp.Weather.get_rating/1` calls directly to `Req.request/1` *in the same process*. It also works in many cases where the request happens in a spawned process, such as a `Task`, `GenServer`, and more. However, if you are encountering issues with stubs not being available in spawned processes, it's likely that you'll need **explicit allowances**. For example, if `MyApp.Weather.get_rating/1` was calling `Req.request/1` in a process spawned with `spawn/1`, the stub would not be available in the spawned process: # With code like this, the stub would not be available in the spawned task: def get_rating_async(location) do spawn(fn -> get_rating(location) end) end To make stubs defined in the test process available in other processes, you can use `allow/3`. For example, imagine that the call to `MyApp.Weather.get_rating/1` was happening in a spawned GenServer: test "nice weather" do {:ok, pid} = start_gen_server(...) Req.Test.stub(MyApp.Weather, fn conn -> Req.Test.json(conn, %{"celsius" => 25.0}) end) Req.Test.allow(MyApp.Weather, self(), pid) assert get_weather(pid, "Krakow, Poland") == {:ok, :nice} end ## Broadway If you're using `Req.Test` with [Broadway](https://hex.pm/broadway), you may need to use `allow/3` to make stubs available in the Broadway processors. A great way to do that is to hook into the [Telemetry](https://hex.pm/telemetry) events that Broadway publishes to manually allow the processors and batch processors to access the stubs. This approach is similar to what is [documented in Broadway itself](https://hexdocs.pm/broadway/Broadway.html#module-testing-with-ecto). First, you should add the test PID (which is allowed to use the Req stub) to the metadata for the test events you're publishing: Broadway.test_message(MyApp.Pipeline, message, metadata: %{req_stub_owner: self()}) Then, you'll need to define a test helper to hook into the Telemetry events. For example, in your `test/test_helper.exs` file: defmodule BroadwayReqStubs do def attach(stub) do events = [ [:broadway, :processor, :start], [:broadway, :batch_processor, :start], ] :telemetry.attach_many({__MODULE__, stub}, events, &__MODULE__.handle_event/4, %{stub: stub}) end def handle_event(_event_name, _event_measurement, %{messages: messages}, %{stub: stub}) do with [%Broadway.Message{metadata: %{req_stub_owner: pid}} | _] <- messages do :ok = Req.Test.allow(stub, pid, self()) end :ok end end Last but not least, attach the helper in your `test/test_helper.exs`: BroadwayReqStubs.attach(MyStub) """ @typep name() :: term() if Code.ensure_loaded?(Plug.Conn) do @typep plug() :: module() | {module(), term()} | (Plug.Conn.t() -> Plug.Conn.t()) | (Plug.Conn.t(), term() -> Plug.Conn.t()) else @typep plug() :: module() | {module, term()} | (conn :: term() -> term()) | (conn :: term(), term() -> term()) end @ownership Req.Test.Ownership @doc """ Sends JSON response. ## Examples iex> plug = fn conn -> ...> Req.Test.json(conn, %{celsius: 25.0}) ...> end iex> iex> resp = Req.get!(plug: plug) iex> resp.headers["content-type"] ["application/json; charset=utf-8"] iex> resp.body %{"celsius" => 25.0} """ if Code.ensure_loaded?(Plug.Test) do @spec json(Plug.Conn.t(), term()) :: Plug.Conn.t() def json(%Plug.Conn{} = conn, data) do send_resp(conn, conn.status || 200, "application/json", Jason.encode_to_iodata!(data)) end defp send_resp(conn, default_status, default_content_type, body) do conn |> ensure_resp_content_type(default_content_type) |> Plug.Conn.send_resp(conn.status || default_status, body) end defp ensure_resp_content_type(%Plug.Conn{resp_headers: resp_headers} = conn, content_type) do if List.keyfind(resp_headers, "content-type", 0) do conn else content_type = content_type <> "; charset=utf-8" %{conn | resp_headers: [{"content-type", content_type} | resp_headers]} end end else def json(_conn, _data) do require Logger Logger.error(""" Could not find plug dependency. Please add :plug to your dependencies: {:plug, "~> 1.0"} """) raise "missing plug dependency" end end @doc """ Sends HTML response. ## Examples iex> plug = fn conn -> ...> Req.Test.html(conn, "

Hello, World!

") ...> end iex> iex> resp = Req.get!(plug: plug) iex> resp.headers["content-type"] ["text/html; charset=utf-8"] iex> resp.body "

Hello, World!

" """ if Code.ensure_loaded?(Plug.Test) do @spec html(Plug.Conn.t(), iodata()) :: Plug.Conn.t() def html(%Plug.Conn{} = conn, data) do send_resp(conn, conn.status || 200, "text/html", data) end else def html(_conn, _data) do require Logger Logger.error(""" Could not find plug dependency. Please add :plug to your dependencies: {:plug, "~> 1.0"} """) raise "missing plug dependency" end end @doc """ Sends text response. ## Examples iex> plug = fn conn -> ...> Req.Test.text(conn, "Hello, World!") ...> end iex> iex> resp = Req.get!(plug: plug) iex> resp.headers["content-type"] ["text/plain; charset=utf-8"] iex> resp.body "Hello, World!" """ if Code.ensure_loaded?(Plug.Test) do @spec text(Plug.Conn.t(), iodata()) :: Plug.Conn.t() def text(%Plug.Conn{} = conn, data) do send_resp(conn, conn.status || 200, "text/plain", data) end else def text(_conn, _data) do require Logger Logger.error(""" Could not find plug dependency. Please add :plug to your dependencies: {:plug, "~> 1.0"} """) raise "missing plug dependency" end end @doc """ Sends redirect response to the given url. This function is adapted from [`Phoenix.Controller.redirect/2`](https://hexdocs.pm/phoenix/Phoenix.Controller.html#redirect/2). For security, `:to` only accepts paths. Use the `:external` option to redirect to any URL. The response will be sent with the status code defined within the connection, via `Plug.Conn.put_status/2`. If no status code is set, a 302 response is sent. ## Examples iex> plug = fn ...> conn when conn.request_path == nil -> ...> Req.Test.redirect(conn, to: "/hello") ...> ...> conn when conn.request_path == "/hello" -> ...> Req.Test.text(conn, "Hello, World!") ...> conn -> dbg(conn) ...> end iex> iex> resp = Req.get!(plug: plug, url: "http://example.com") # 14:53:06.101 [debug] redirecting to /hello iex> resp.body "Hello, World!" """ def redirect(conn, opts) if Code.ensure_loaded?(Plug.Conn) do def redirect(conn, opts) when is_list(opts) do url = url(opts) html = Plug.HTML.html_escape(url) body = "You are being redirected." conn = if List.keyfind(conn.resp_headers, "content-type", 0) do conn else content_type = "text/html; charset=utf-8" update_in(conn.resp_headers, &[{"content-type", content_type} | &1]) end conn |> Plug.Conn.put_resp_header("location", url) |> Plug.Conn.send_resp(conn.status || 302, body) end defp url(opts) do cond do to = opts[:to] -> validate_local_url(to) external = opts[:external] -> external true -> raise ArgumentError, "expected :to or :external option in redirect/2" end end @invalid_local_url_chars ["\\", "/%09", "/\t"] defp validate_local_url("//" <> _ = to), do: raise_invalid_url(to) defp validate_local_url("/" <> _ = to) do if String.contains?(to, @invalid_local_url_chars) do raise ArgumentError, "unsafe characters detected for local redirect in URL #{inspect(to)}" else to end end defp validate_local_url(to), do: raise_invalid_url(to) defp raise_invalid_url(url) do raise ArgumentError, "the :to option in redirect expects a path but was #{inspect(url)}" end else def redirect(_conn, _opts) do require Logger Logger.error(""" Could not find plug dependency. Please add :plug to your dependencies: {:plug, "~> 1.0"} """) raise "missing plug dependency" end end @doc """ Simulates a network transport error. ## Examples iex> plug = fn conn -> ...> Req.Test.transport_error(conn, :timeout) ...> end iex> iex> Req.get(plug: plug, retry: false) {:error, %Req.TransportError{reason: :timeout}} """ @doc since: "0.5.0" def transport_error(conn, reason) if Code.ensure_loaded?(Plug.Conn) do @spec transport_error(Plug.Conn.t(), reason :: atom()) :: Plug.Conn.t() def transport_error(%Plug.Conn{} = conn, reason) do validate_transport_error!(reason) exception = Req.TransportError.exception(reason: reason) put_in(conn.private[:req_test_exception], exception) end defp validate_transport_error!(:protocol_not_negotiated), do: :ok defp validate_transport_error!({:bad_alpn_protocol, _}), do: :ok defp validate_transport_error!(:closed), do: :ok defp validate_transport_error!(:timeout), do: :ok defp validate_transport_error!(reason) do case :ssl.format_error(reason) do ~c"Unexpected error:" ++ _ -> raise ArgumentError, """ unexpected Req.TransportError reason: #{inspect(reason)} This function only accepts error reasons that can happen in production, for example: `:closed`, `:timeout`, `:econnrefused`, etc. """ _ -> :ok end end else def transport_error(_conn, _reason) do require Logger Logger.error(""" Could not find plug dependency. Please add :plug to your dependencies: {:plug, "~> 1.0"} """) raise "missing plug dependency" end end @doc false @deprecated "Don't manually fetch stubs. See the documentation for Req.Test instead." def stub(name) do __fetch_plug__(name) end def __fetch_plug__(name) do case Req.Test.Ownership.fetch_owner(@ownership, callers(), name) do {tag, owner} when is_pid(owner) and tag in [:ok, :shared_owner] -> result = Req.Test.Ownership.get_and_update(@ownership, owner, name, fn %{expectations: [value | rest]} = map -> {{:ok, value}, put_in(map[:expectations], rest)} %{stub: value} = map -> {{:ok, value}, map} %{expectations: []} = map -> {{:error, :no_expectations_and_no_stub}, map} end) case result do {:ok, {:ok, value}} -> value {:ok, {:error, :no_expectations_and_no_stub}} -> raise "no mock or stub for #{inspect(name)}" end :error -> raise "cannot find mock/stub #{inspect(name)} in process #{inspect(self())}" end end defguardp is_plug(value) when is_function(value, 1) or is_function(value, 2) or is_atom(value) or (is_tuple(value) and tuple_size(value) == 2 and is_atom(elem(value, 0))) @doc """ Creates a request stub with the given `name` and `plug`. Req allows running requests against _plugs_ (instead of over the network) using the [`:plug`](`Req.Steps.run_plug/1`) option. However, passing the `:plug` value throughout the system can be cumbersome. Instead, you can tell Req to find plugs by `name` by setting `plug: {Req.Test, name}`, and register plug stubs for that `name` by calling `Req.Test.stub(name, plug)`. In other words, multiple concurrent tests can register test stubs under the same `name`, and when Req makes the request, it will find the appropriate implementation, even when invoked from different processes than the test process. The `name` can be any term. The `plug` can be one of: * A _function_ plug: a `fun(conn)` or `fun(conn, options)` function that takes a `Plug.Conn` and returns a `Plug.Conn`. * A _module_ plug: a `module` name or a `{module, options}` tuple. ## Examples iex> Req.Test.stub(MyStub, fn conn -> ...> send(self(), :req_happened) ...> Req.Test.json(conn, %{}) ...> end) :ok iex> Req.get!(plug: {Req.Test, MyStub}).body %{} iex> receive do ...> :req_happened -> :ok ...> end :ok """ @doc type: :mock @spec stub(name(), plug()) :: :ok def stub(name, plug) when is_plug(plug) do {:ok, :ok} = Req.Test.Ownership.get_and_update(@ownership, self(), name, fn map_or_nil -> {:ok, put_in(map_or_nil || %{}, [:stub], plug)} end) :ok end @doc """ Creates a request expectation with the given `name` and `plug`, expected to be fetched at most `n` times, **in order**. This function allows you to expect a `n` number of request and handle them **in order** via the given `plug`. It is safe to use in concurrent tests. If you fetch the value under `name` more than `n` times, this function raises a `RuntimeError`. The `name` can be any term. The `plug` can be one of: * A _function_ plug: a `fun(conn)` or `fun(conn, options)` function that takes a `Plug.Conn` and returns a `Plug.Conn`. * A _module_ plug: a `module` name or a `{module, options}` tuple. See `stub/2` and module documentation for more information. `verify_on_exit!/1` can be used to ensure that all defined expectations have been called. ## Examples Let's simulate a server that is having issues: on the first request it is not responding and on the following two requests it returns an HTTP 500. Only on the third request it returns an HTTP 200. Req by default automatically retries transient errors (using `Req.Steps.retry/1`) so it will make multiple requests exercising all of our request expectations: iex> Req.Test.expect(MyStub, &Req.Test.transport_error(&1, :econnrefused)) iex> Req.Test.expect(MyStub, 2, &Plug.Conn.send_resp(&1, 500, "internal server error")) iex> Req.Test.expect(MyStub, &Plug.Conn.send_resp(&1, 200, "ok")) iex> Req.get!(plug: {Req.Test, MyStub}).body # 15:57:06.309 [warning] retry: got exception, will retry in 1000ms, 3 attempts left # 15:57:06.309 [warning] ** (Req.TransportError) connection refused # 15:57:07.310 [warning] retry: got response with status 500, will retry in 2000ms, 2 attempts left # 15:57:09.311 [warning] retry: got response with status 500, will retry in 4000ms, 1 attempt left "ok" iex> Req.request!(plug: {Req.Test, MyStub}) ** (RuntimeError) no mock or stub for MyStub """ @doc since: "0.4.15" @doc type: :mock @spec expect(name(), pos_integer(), plug()) :: name() def expect(name, n \\ 1, plug) when is_integer(n) and n > 0 do plugs = List.duplicate(plug, n) {:ok, :ok} = Req.Test.Ownership.get_and_update(@ownership, self(), name, fn map_or_nil -> {:ok, Map.update(map_or_nil || %{}, :expectations, plugs, &(&1 ++ plugs))} end) name end @doc """ Allows `pid_to_allow` to access `name` provided that `owner` is already allowed. """ @doc type: :mock @spec allow(name(), pid(), pid() | (-> pid())) :: :ok | {:error, Exception.t()} def allow(name, owner, pid_to_allow) when is_pid(owner) do Req.Test.Ownership.allow(@ownership, owner, pid_to_allow, name) end @doc """ Sets the `Req.Test` mode to "global", meaning that the stubs are shared across all tests and cannot be used concurrently. """ @doc since: "0.5.0" @doc type: :mock @spec set_req_test_to_shared(ex_unit_context :: term()) :: :ok def set_req_test_to_shared(_context \\ %{}) do Req.Test.Ownership.set_mode_to_shared(@ownership, self()) end @doc """ Sets the `Req.Test` mode to "private", meaning that stubs can be shared across tests concurrently. """ @doc type: :mock @doc since: "0.5.0" @spec set_req_test_to_private(ex_unit_context :: term()) :: :ok def set_req_test_to_private(_context \\ %{}) do Req.Test.Ownership.set_mode_to_private(@ownership) end @doc """ Sets the `Req.Test` mode based on the given `ExUnit` context. This works as a ExUnit callback: setup :set_req_test_from_context """ @doc since: "0.5.0" @doc type: :mock @spec set_req_test_from_context(ex_unit_context :: term()) :: :ok def set_req_test_from_context(_context \\ %{}) def set_req_test_from_context(%{async: true} = context), do: set_req_test_to_private(context) def set_req_test_from_context(context), do: set_req_test_to_shared(context) @doc """ Sets a ExUnit callback to verify the expectations on exit. Similar to calling `verify!/0` at the end of your test. This works as a ExUnit callback: setup {Req.Test, :verify_on_exit!} """ @doc since: "0.5.0" @doc type: :mock @spec verify_on_exit!(term()) :: :ok def verify_on_exit!(_context \\ %{}) do pid = self() Req.Test.Ownership.set_owner_to_manual_cleanup(@ownership, pid) ExUnit.Callbacks.on_exit(Req.Test, fn -> verify(pid, :all) Req.Test.Ownership.cleanup_owner(@ownership, pid) end) end @doc """ Verifies that all the plugs expected to be executed within any scope have been executed. """ @doc since: "0.5.0" @doc type: :mock @spec verify!() :: :ok def verify! do verify(self(), :all) end @doc """ Verifies that all the plugs expected to be executed within the scope of `name` have been executed. """ @doc type: :mock @doc since: "0.5.0" @spec verify!(name()) :: :ok def verify!(name) do verify(self(), name) end defp verify(owner_pid, mock_or_all) do messages = for {name, stubs_and_expecs} <- Req.Test.Ownership.get_owned(@ownership, owner_pid, _default = %{}, 5000), name == mock_or_all or mock_or_all == :all, pending_count = stubs_and_expecs |> Map.get(:expectations, []) |> length(), pending_count > 0 do " * expected #{inspect(name)} to be still used #{pending_count} more times" end if messages != [] do raise "error while verifying Req.Test expectations for #{inspect(owner_pid)}:\n\n" <> Enum.join(messages, "\n") end :ok end ## Helpers defp callers do [self() | Process.get(:"$callers") || []] end ## Plug callbacks if Code.ensure_loaded?(Plug) do @behaviour Plug end @doc false def init(name) do name end @doc false def call(conn, name) do case __fetch_plug__(name) do fun when is_function(fun, 1) -> fun.(conn) fun when is_function(fun, 2) -> fun.(conn, []) module when is_atom(module) -> module.call(conn, module.init([])) {module, options} when is_atom(module) -> module.call(conn, module.init(options)) other -> raise """ expected plug to be one of: * fun(conn) * fun(conn, options) * module * {module, options} got: #{inspect(other)}\ """ end end @doc """ Reads the raw request body from a plug request. ## Examples iex> echo = fn conn -> ...> body = Req.Test.raw_body(conn) ...> Plug.Conn.send_resp(conn, 200, body) ...> end iex> iex> resp = Req.post!(plug: echo, json: %{hello: "world"}) iex> resp.body "{\\"hello\\":\\"world\\"}" """ if Code.ensure_loaded?(Plug.Test) do @spec raw_body(Plug.Conn.t()) :: iodata() def raw_body(%Plug.Conn{} = conn) do {Req.Test.Adapter, %{raw_body: raw_body}} = conn.adapter raw_body end else def raw_body(_conn) do require Logger Logger.error(""" Could not find plug dependency. Please add :plug to your dependencies: {:plug, "~> 1.0"} """) raise "missing plug dependency" end end end