From ff43004e6b0e328fc7877fa17f19998a7384d838 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 23 Oct 2024 01:11:26 -0400 Subject: [PATCH] feat: add mox integration test support --- lib/tesla/adapter.ex | 4 +- lib/tesla/mock.ex | 4 +- lib/tesla/tesla_mox.ex | 192 +++++++++++++++++++++++++++++++++++++++++ lib/tesla/test.ex | 74 ++++++++++++++++ mix.exs | 5 +- mix.lock | 2 +- 6 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 lib/tesla/tesla_mox.ex create mode 100644 lib/tesla/test.ex diff --git a/lib/tesla/adapter.ex b/lib/tesla/adapter.ex index ded7c40c..3235297a 100644 --- a/lib/tesla/adapter.ex +++ b/lib/tesla/adapter.ex @@ -48,6 +48,8 @@ defmodule Tesla.Adapter do """ + @type options :: any() + @doc """ Invoked when a request runs. @@ -56,7 +58,7 @@ defmodule Tesla.Adapter do - `env` - `Tesla.Env` struct that stores request/response data - `options` - middleware options provided by user """ - @callback call(env :: Tesla.Env.t(), options :: any) :: Tesla.Env.result() + @callback call(env :: Tesla.Env.t(), options :: options()) :: Tesla.Env.result() @doc """ Helper function that merges all adapter options. diff --git a/lib/tesla/mock.ex b/lib/tesla/mock.ex index 3d126387..adbff076 100644 --- a/lib/tesla/mock.ex +++ b/lib/tesla/mock.ex @@ -186,8 +186,8 @@ defmodule Tesla.Mock do import Tesla.Mock mock fn - %{url: "/ok"} -> text(%{"some" => "data"}) - %{url: "/404"} -> text(%{"some" => "data"}, status: 404) + %{url: "/ok"} -> text("200 ok") + %{url: "/404"} -> text("404 not found", status: 404) end """ @spec text(body :: term, opts :: response_opts) :: Tesla.Env.t() diff --git a/lib/tesla/tesla_mox.ex b/lib/tesla/tesla_mox.ex new file mode 100644 index 00000000..1f2e4cae --- /dev/null +++ b/lib/tesla/tesla_mox.ex @@ -0,0 +1,192 @@ +if Code.ensure_loaded?(Mox) do + defmodule Tesla.TeslaMox do + @moduledoc """ + Integrate `Tesla` with `Mox`. Provides helpers to expect calls on the + adapter and assert on the received calls. + + ## Getting started + + Make sure to have `Mox` setup in your project. We are assuming that you have + experience with `Mox`. Please verify that `Mox` is properly setup in your + project. + + Define the `Mox` mocked adapter + + ```elixir + # test/support/mocks.ex + Mox.defmock(Test.TeslaAdapterMock, for: Tesla.Adapter) + ``` + + Assuming that you are using the global adapter, set the mocked adapter in + your `config/test.exs` configuration: + + ```elixir + # config/test.exs + config :tesla, adapter: Test.TeslaAdapterMock + ``` + + If you are not using the global adapter, please configure your setup to use + the `Test.TeslaAdapterMock` adapter, in this case. + + Require this module in your test case, in order to use the macros provided + by this module: + + defmodule MyAppTest do + use ExUnit.Case, async: true + require Tesla.TeslaMox + + # ... + end + + Expect that a given adapter receives a call, and make the call: + + defmodule MyAppTest do + use ExUnit.Case, async: true + require Tesla.TeslaMox, as: TeslaMox + + test "creating a user" do + Tesla.TeslaMox.expect_tesla_call( + times: 2, + returns: %Tesla.Env{status: 200} + ) + + assert :ok = create_user!() + + TeslaMox.assert_received_tesla_call(env) + assert env.status == 200 + end + + defp create_user! do + # ... + Tesla.post!("https://acme.com/users") + # ... + :ok + end + end + """ + + alias Tesla.Testing.TeslaMox + + import ExUnit.Assertions + + @doc """ + Expect a call on the given adapter using `Mox.expect/4`. + + - `opts` - Extra configuration options. + - `:times` - Required. The number of times to expect the call. + - `:returns` - Required. The value to return from the adapter. + - `:send_to` - Optional. The process to send the message to. Defaults to + the current process. + - `:adapter` - Optional. The adapter to expect the call on. Falls back to + the `:tesla` application configuration. + + ## Examples + + Returning a `t:Tesla.Env.t/0` struct with a `200` status: + + Tesla.TestMox.expect_tesla_call( + times: 2, + returns: %Tesla.Env{status: 200} + ) + + Changing the `Mox` mocked adapter: + + Tesla.TestMox.expect_tesla_call( + times: 2, + returns: %Tesla.Env{status: 200}, + adapter: MyApp.MockAdapter + ) + """ + def expect_tesla_call(opts) do + n_time = Keyword.get(opts, :times, 1) + adapter = fetch_adapter!(opts) + send_to = self() + + Mox.expect(adapter, :call, n_time, fn given_env, given_opts -> + if send_to != nil do + message = {adapter, :call, [given_env, given_opts]} + send(send_to, {TeslaMox, message}) + end + + case Keyword.fetch!(opts, :returns) do + fun when is_function(fun) -> + fun.(given_env, given_opts) + + %Tesla.Env{} = value -> + {:ok, Map.merge(given_env, Map.take(value, [:body, :headers, :status]))} + + {:error, error} -> + {:error, error} + end + end) + end + + @doc """ + Assert that the current process's mailbox contains a `TeslaMox` message. + It uses `assert_received/1` under the hood. + + - `expected_env` - The expected `t:Tesla.Env.t/0` passed to the adapter. + - `expected_opts` - The expected `t:Tesla.Adapter.options/0` passed to the + adapter. + - `opts` - Extra configuration options. + - `:adapter` - Optional. The adapter to expect the call on. Falls back to + the `:tesla` application configuration. + + ## Examples + + Asserting that the adapter received a `t:Tesla.Env.t/0` struct with a `200` + status: + + Tesla.TestMox.assert_received_tesla_call(env) + assert env.status == 200 + assert env.body == "OK" + """ + defmacro assert_received_tesla_call(expected_env, expected_opts \\ [], opts \\ []) do + adapter = fetch_adapter!(opts) + + quote do + assert_received {TeslaMox, + {unquote(adapter), :call, + [unquote(expected_env), unquote(expected_opts)]}} + end + end + + @doc """ + Assert that the current process's mailbox does not contain any `TeslaMox` + messages. + + This is useful to assert that all expected operations were received using + `assert_received_tesla_call/2`. + """ + defmacro assert_tesla_empty_mailbox do + quote do + refute_received {TeslaMox, _} + end + end + + defp fetch_adapter!(opts) do + adapter = + Keyword.get_lazy(opts, :adapter, fn -> + Application.get_env(:tesla, :adapter) + end) + + case adapter do + nil -> + raise ArgumentError, """ + expected :adapter to be defined + + Set in the opts[:adapter] + + Tesla.TestMox.expect_tesla_call(adapter: MyApp.MockAdapter) + + Or set in the `config/test.exs` configuration: + + config :tesla, :adapter, MyApp.MockAdapter + """ + + adapter -> + adapter + end + end + end +end diff --git a/lib/tesla/test.ex b/lib/tesla/test.ex new file mode 100644 index 00000000..3809dfc4 --- /dev/null +++ b/lib/tesla/test.ex @@ -0,0 +1,74 @@ +defmodule Tesla.Test do + @moduledoc """ + Provides utilities for testing Tesla-based HTTP clients. + """ + + import ExUnit.Assertions + + @doc """ + Asserts that two `t:Tesla.Env.t/0` structs are matches. + + ## Parameters + + - `given_env` - The actual `t:Tesla.Env.t/0` struct received from the request. + - `expected_env` - The expected `t:Tesla.Env.t/0` struct to compare against. + - `opts` - Additional options for fine-tuning the assertion (optional). + - `:exclude_headers` - A list of header keys to exclude from the assertion. + By default, the "traceparent" header is always excluded. + + For the `body`, the function attempts to parse JSON and URL-encoded content + when appropriate. + + This function is designed to be used in conjunction with + `Tesla.TeslaMox.assert_received_tesla_call/1` for comprehensive request + testing. + + ## Examples + + defmodule MyTest do + use ExUnit.Case, async: true + + require Tesla.Test + require Tesla.TeslaMox + + test "returns a 200 status" do + Tesla.TeslaMox.expect_tesla_call( + times: 2, + returns: %Tesla.Env{status: 200} + ) + + # ... do some work ... + Tesla.post!("https://acme.com/users") + # ... + + Tesla.TeslaMox.assert_received_tesla_call(given_env, _) + Tesla.Test.assert_tesla_env(given_env, %Tesla.Env{ + method: :post, + url: "https://acme.com/users", + }) + end + end + """ + def assert_tesla_env(%Tesla.Env{} = given_env, %Tesla.Env{} = expected_env, opts \\ []) do + exclude_headers = + opts + |> Keyword.get(:exclude_headers, []) + |> Enum.concat(["traceparent"]) + + headers = for {key, value} <- given_env.headers, key not in exclude_headers, do: {key, value} + + assert given_env.method == expected_env.method + assert given_env.url == expected_env.url + assert headers == expected_env.headers + assert given_env.query == expected_env.query + assert read_body!(given_env) == expected_env.body + end + + defp read_body!(%Tesla.Env{} = env) do + case Tesla.get_headers(env, "content-type") do + ["application/json" | _] -> Jason.decode!(env.body, keys: :atoms) + ["application/x-www-form-urlencoded" | _] -> URI.decode_query(env.body) + _ -> env.body + end + end +end diff --git a/mix.exs b/mix.exs index db6a1877..a24b9522 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule Tesla.Mixfile do # other {:fuse, "~> 2.4", optional: true}, {:telemetry, "~> 0.4 or ~> 1.0", optional: true}, + {:mox, "~> 1.0.0", optional: true}, # devtools {:opentelemetry_process_propagator, ">= 0.0.0", only: [:test, :dev]}, @@ -78,7 +79,6 @@ defmodule Tesla.Mixfile do {:mix_test_watch, ">= 0.0.0", only: :dev}, {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, {:inch_ex, ">= 0.0.0", only: :docs}, - {:mox, ">= 0.0.0", only: :test}, # httparrot dependencies {:httparrot, "~> 1.4", only: :test}, @@ -112,7 +112,8 @@ defmodule Tesla.Mixfile do Tesla.Middleware ], Adapters: [~r/Tesla.Adapter./], - Middlewares: [~r/Tesla.Middleware./] + Middlewares: [~r/Tesla.Middleware./], + TestSupport: [~r/Tesla.TestSupport./] ], nest_modules_by_prefix: [ Tesla.Adapter, diff --git a/mix.lock b/mix.lock index 1327c3ba..747f646e 100644 --- a/mix.lock +++ b/mix.lock @@ -32,7 +32,7 @@ "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, - "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, + "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, "msgpax": {:hex, :msgpax, "2.4.0", "4647575c87cb0c43b93266438242c21f71f196cafa268f45f91498541148c15d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "ca933891b0e7075701a17507c61642bf6e0407bb244040d5d0a58597a06369d2"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.0", "3f87744d42c21b2042a0aa1d48c83c77e6dd9dd357e425a038dd4b49ba8b79a1", [:mix], [], "hexpm", "7c16cc74f4e952464220a73055b557a273e8b1b7ace8489ec9d86e9ad56cb2cc"},