Skip to content

Commit

Permalink
feat: add mox integration test support
Browse files Browse the repository at this point in the history
  • Loading branch information
yordis committed Oct 23, 2024
1 parent 1baedf2 commit ff43004
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 6 deletions.
4 changes: 3 additions & 1 deletion lib/tesla/adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ defmodule Tesla.Adapter do
"""

@type options :: any()

@doc """
Invoked when a request runs.
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/tesla/mock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
192 changes: 192 additions & 0 deletions lib/tesla/tesla_mox.ex
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions lib/tesla/test.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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]},
Expand All @@ -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},
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down

0 comments on commit ff43004

Please sign in to comment.