-
Notifications
You must be signed in to change notification settings - Fork 349
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add mox integration test support
- Loading branch information
Showing
6 changed files
with
275 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters