defmodule GitHub.Testing do
@moduledoc """
Support for interacting with the client in a test environment
> #### Note {:.info}
>
> The current method of tracking and mocking API calls uses the process dictionary for storage.
> This means that tests can be run async (calls from one process will not affect calls from
> another). However, it also means that calls from an async task will not be tracked by the test
> process. If this affects you, please open an issue to discuss possible solutions.
## Usage
The testing facility provided by this library has two parts: this module, which provides helpful
functions for generating data and asserting that API calls are made, and
`GitHub.Plugin.TestClient`, which provides a basic mock and support for the test assertions.
In your test environment, you likely want to use the test client plugin as the only item in the
client stack:
# config/test.exs
config :oapi_github, stack: [{GitHub.Plugin.TestClient, :request, []}]
The stack can also be configured at runtime by passing the `:stack` option to any operation.
> #### Warning {:.warning}
>
> Stack entries without options, like `{GitHub.Plugin.TestClient, :request}`, look like keyword
> list items. If you have stacks configured in multiple Mix environments that all use this
> 2-tuple format, Elixir will try to merge them as keyword lists. Adding an empty options
> element to any stack item will prevent this behaviour.
Then, in your test file, `use` this module and make use of the available assertions:
defmodule MyApp.MyTest do
use ExUnit.Case
use GitHub.Testing
test "something" do
my_function()
assert_gh_called &GitHub.Repos.get/2
end
end
The `use` macro also imports `mock_gh/3` and `generate_gh/2` for use in tests.
"""
require ExUnit.Assertions
alias GitHub.Operation
alias GitHub.Testing.Mock
@doc false
defmacro __using__(_) do
quote do
import GitHub.Testing,
only: [
assert_gh_called: 1,
assert_gh_called: 2,
generate_gh: 1,
generate_gh: 2,
generate_gh: 3,
mock_gh: 2,
mock_gh: 3
]
end
end
# Convenience guard for generate_gh/3
defguardp is_enum(value) when is_map(value) or is_list(value)
#
# Data Generation
#
@doc """
Generate a struct for use in a test response
The first argument is the module / schema you would like to generate. If there are multiple
types available in the module, then the second argument can distinguish which to use (for
example, `:full` for the type `GitHub.PullRequest.full()`). It is also possible to override
the generated fields with custom data.
This function uses randomness to help avoid collisions in tests. For more information, see
`generate/3`.
## Examples
iex> GitHub.Testing.generate(GitHub.PullRequest)
%GitHub.PullRequest{}
iex> GitHub.Testing.generate(GitHub.User, :private)
%GitHub.User{}
iex> GitHub.Testing.generate(GitHub.User, bio: "This is a custom bio")
%GitHub.User{bio: "This is a custom bio"}
iex> GitHub.Testing.generate(GitHub.User, :private, bio: "This is a custom bio")
%GitHub.User{bio: "This is a custom bio"}
"""
@spec generate_gh(module, atom, map | keyword) :: any
def generate_gh(schema, type \\ :t, overrides \\ %{})
def generate_gh(schema, overrides, _overrides) when is_enum(overrides) do
overrides = if is_map(overrides), do: overrides, else: Enum.into(overrides, %{})
generate(nil, nil, {schema, :t})
|> override_fields(overrides)
end
def generate_gh(schema, type, overrides) when is_atom(type) and is_enum(overrides) do
overrides = if is_map(overrides), do: overrides, else: Enum.into(overrides, %{})
generate(nil, nil, {schema, type})
|> override_fields(overrides)
end
def generate_gh(_schema, _type, _overrides) do
raise ArgumentError, """
Expected one of the following forms when calling generate_gh(...):
generate_gh(SchemaModule)
generate_gh(SchemaModule, :type)
generate_gh(SchemaModule, :type, overrides)
generate_gh(SchemaModule, overrides)
Where overrides is an atom-keyed map or keyword list of fields to change in the result.
"""
end
@spec override_fields(struct | map, map) :: struct | map | no_return
defp override_fields(%_{} = record, attrs), do: struct!(record, attrs)
defp override_fields(%{} = record, attrs), do: Map.merge(record, attrs)
@doc """
Generate random data for use in a test response
This function uses randomness to help avoid collisions in tests. It also supports special cases
based on the context of the generated data (the schema and/or key). Examples can be found in the
**Special Cases** section below.
## Special Cases
* `:id` fields with type `:integer` will have unique positive integers rather than the limited
range of integers returned by a typical `:integer` field.
* `:string` fields with keys that end with `_at` will have ISO 8601 datetime strings from a
random time within the past day.
* `:string` fields with keys named `:url` or that end with `_url` will have random URL strings.
Additional special cases can be added to make the generated data more typical of real responses.
For example, returning a name-like string for user names, and limiting the allowed characters
for usernames.
> #### Note {:.info}
>
> If you see an opportunity to improve the generated data for a particular field, please include
> the description of the field from
> [GitHub's OpenAPI specification](https://github.com/github/rest-api-description)
> in your pull request.
## Examples
iex> GitHub.Testing.generate(nil, nil, {GitHub.Repository, :full})
%GitHub.Repository{...}
iex> GitHub.Testing.generate(GitHub.Repository, :id, :integer)
226194162
"""
@spec generate(module, atom, Operation.type()) :: any
def generate(schema, key, type)
# Special cases
def generate(_schema, :email, :string), do: Faker.Internet.email()
def generate(_schema, :id, :integer), do: System.unique_integer([:positive])
def generate(_schema, :login, :string), do: Faker.Internet.user_name()
def generate(_schema, :url, :string), do: Faker.Internet.url()
def generate(GitHub.User, :name, :string), do: Faker.Person.name()
def generate(GitHub.User, :type, :string), do: Enum.random(["User", "Bot", "Organization"])
# Primitive types
def generate(_schema, _key, :binary), do: Faker.String.base64()
def generate(_schema, _key, :boolean), do: Enum.random([true, false])
def generate(_schema, _key, :integer), do: Enum.random(1..10)
def generate(_schema, _key, :map), do: %{}
def generate(_schema, _key, :number) do
Enum.random([
fn -> Enum.random(1..10) end,
fn -> Faker.random_uniform() * 10 end
]).()
end
def generate(_schema, key, :string) do
string_key = Atom.to_string(key)
cond do
String.ends_with?(string_key, "_at") ->
now = DateTime.utc_now()
yesterday = DateTime.add(now, -1, :day)
Faker.DateTime.between(yesterday, now)
|> DateTime.to_iso8601()
String.ends_with?(string_key, "_url") ->
Faker.Internet.url()
:else ->
Faker.Lorem.characters(15..30) |> to_string()
end
end
def generate(_schema, _key, :null), do: nil
def generate(_schema, _key, :unknown), do: nil
# Compound types
def generate(schema, key, {:array, type}), do: [generate(schema, key, type)]
def generate(schema, key, {:union, types}) do
Enum.map(types, fn type ->
fn -> generate(schema, key, type) end
end)
|> Enum.random()
|> apply([])
end
def generate(schema, key, {:nullable, type}) do
Enum.random([
fn -> nil end,
fn -> generate(schema, key, type) end
]).()
end
def generate(_schema, _key, {module, struct_type}) do
apply(module, :__fields__, [struct_type])
|> Enum.map(fn {key, field_type} -> {key, generate(module, key, field_type)} end)
|> then(fn fields -> struct!(module, fields) end)
end
#
# Mocks
#
@pd_mock_key :oapi_github_test_mock
@doc """
Mock a response for an API endpoint
## API Endpoint
The API endpoint can be passed as a function call or using function capture syntax. If passed
as a function call, the arguments must match exactly or use the special value `:_` to match any
value. If passed using function capture syntax, only the arity will be matched. The options
argument is not considered during these checks.
## Return Value
As a return value for a mock, you can set a plain value or pass one of several function forms:
* A zero-arity function will be evaluated a call time. Use this to perform lazy evaluation of
the mock or encapsulate a generator.
* A function with the same number of arguments as the original client operation (**not**
including the final `opts` argument) will be called with the same arguments. Use this to
perform assertions on the arguments or return a different value depending on the call.
* A function with the same number of arguments as the original client operation (including the
final `opts` argument) will be called with the same arguments and options. Use this to perform
assertions on the arguments and options or return a different value depending on the call.
In each case, the plain value — or the value returned from the function — should have one of the
following forms:
{:ok, data}
{:ok, data, opts}
{:error, error}
{:error, error, opts}
Where `data` is the response body and `error` is the error to return, and `opts` modifies the
response. The available options are:
* `code` (integer): Status code to include with the response.
In addition, the following pre-defined error responses are available:
* `{:error, :not_found}` will return an error matching GitHub's standard "Not Found" response.
* `{:error, :rate_limited}` with return an error matching a GitHub API rate limited response.
* `{:error, :unauthorized}` will return an error matching GitHub's unauthorized response.
## Examples
mock_gh GitHub.Repos.get("owner", "repo"), {:ok, %GitHub.Repository{}}
mock_gh GitHub.Repos.get("owner", "repo-2"), {:ok, %GitHub.Repository{}, code: 201}
mock_gh GitHub.Repos.get("friend", "repo"), {:error, :not_found}
mock_gh GitHub.Repos.get("friend", "repo-2"), {:error, %GitHub.Error{}, code: 403}
mock_gh &GitHub.Repos.get/2, fn -> {:ok, %GitHub.Repository{}} end
mock_gh &GitHub.Repos.get/2, fn owner, name ->
assert String.starts_with?(name, "oapi_")
{:ok, %GitHub.Repository{owner: owner, name: name}}
end
mock_gh &GitHub.Repos.get/2, fn owner, name, opts ->
assert opts[:auth] == "gho_token"
{:ok, %GitHub.Repository{owner: owner, name: name}}
end
"""
defmacro mock_gh(call, return_fn, opts \\ [])
defmacro mock_gh({{:., _, [module, function]}, _, args}, return_fn, opts) do
module = Macro.expand(module, __CALLER__)
quote do
GitHub.Testing.put_mock(
unquote(module),
unquote(function),
unquote(args),
unquote(return_fn),
unquote(opts)
)
end
end
defmacro mock_gh(
{:&, _, [{:/, _, [{{:., _, [module, function]}, _, _}, arity]}]},
return_fn,
opts
) do
module = Macro.expand(module, __CALLER__)
quote do
GitHub.Testing.put_mock(
unquote(module),
unquote(function),
unquote(arity),
unquote(return_fn),
unquote(opts)
)
end
end
# Not ready for public use
@doc false
@spec get_mock_result(Operation.t()) :: Mock.return()
def get_mock_result(operation) do
{module, function, args} = Operation.get_caller(operation)
options = Operation.get_options(operation)
Process.get(@pd_mock_key, %{})
|> Map.get({module, function})
|> Mock.choose(args)
|> case do
%{args: ^args, return: mock_return} ->
evaluate_mock(mock_return, args, options)
%{return: mock_return} ->
return_value = evaluate_mock(mock_return, args, options)
put_mock(module, function, args, return_value, implicit: true)
nil ->
mock_return_fn = default_response_fn(operation)
put_mock(module, function, length(args), mock_return_fn, implicit: true)
put_mock(module, function, args, mock_return_fn.(), implicit: true)
end
end
@spec evaluate_mock(Mock.return_fun(), [any], keyword) :: Mock.return()
defp evaluate_mock(fun, _args, _opts) when is_function(fun, 0), do: fun.()
defp evaluate_mock(fun, args, _opts) when is_function(fun, length(args)), do: apply(fun, args)
defp evaluate_mock(fun, args, opts) when is_function(fun, length(args) + 1),
do: apply(fun, args ++ [opts])
defp evaluate_mock(return, _args, _opts), do: return
@spec default_response_fn(Operation.t()) :: Mock.return_fun()
defp default_response_fn(%Operation{response_types: [{code, type} | _]}) do
fn -> {:ok, GitHub.Testing.generate(nil, nil, type), code: code} end
end
@doc false
@spec put_mock(module, atom, Mock.args(), Mock.return_fun(), keyword) :: Mock.t()
def put_mock(module, function, args, return, opts \\ []) do
implicit = opts[:implicit] == true
limit = opts[:limit] || :infinity
all_mocks = Process.get(@pd_mock_key, %{})
new_mock = %Mock{args: args, implicit: implicit, limit: limit, return: return}
new_mocks = [new_mock | Map.get(all_mocks, {module, function}, [])]
updated_mocks = Map.put(all_mocks, {module, function}, new_mocks)
Process.put(@pd_mock_key, updated_mocks)
return
end
#
# Calls
#
@pd_call_key :oapi_github_test_call
@typep call :: {module :: module, function :: atom, args :: [any]}
@doc """
Assert the number of times an API endpoint was called
The API endpoint can be passed as a function call or using function capture syntax. If passed
as a function call, the arguments must match exactly or use the special value `:_` to match any
value. If passed using function capture syntax, only the arity will be matched. The options
argument is not considered during these checks.
## Examples
assert_gh_called GitHub.Repos.get("owner", "repo")
assert_gh_called GitHub.Repos.get("owner", :_), times: 2
assert_gh_called GitHub.Repos.get(owner, "repo"), min: 2, max: 3
assert_gh_called &GitHub.Repos.get/2, times: 0
## Options
* `max`: Non-negative integer representing the maximum number of times a matching call should
have occurred. If unspecified, there is no upper limit on the acceptable number of calls.
If none of `times`, `min`, or `max` are specified, the default is to assert at least
one matching call.
* `min`: Non-negative integer representing the minimum number of times a matching call should
have occurred. If unspecified, there is no lower limit on the acceptable number of calls.
If none of `times`, `min`, or `max` are specified, the default is to assert at least
one matching call.
* `times`: Non-negative integer number of times a matching call should have occurred. Passing
zero will assert the endpoint has not been called. This option has precedence over `min` and
`max`. If none of `times`, `min`, or `max` are specified, the default is to assert at least
one matching call.
"""
defmacro assert_gh_called(call, opts \\ [])
defmacro assert_gh_called({{:., _, [module, function]}, _, args}, opts) do
module = Macro.expand(module, __CALLER__)
quote do
GitHub.Testing.assert_call_count(
unquote(module),
unquote(function),
unquote(args),
unquote(opts)
)
end
end
defmacro assert_gh_called(
{:&, _, [{:/, _, [{{:., _, [module, function]}, _, _}, arity]}]},
opts
) do
module = Macro.expand(module, __CALLER__)
quote do
GitHub.Testing.assert_call_count(
unquote(module),
unquote(function),
unquote(arity),
unquote(opts)
)
end
end
defmacro assert_gh_called(_call, _opts) do
raise ArgumentError, "Unknown form passed to `assert_gh_called`"
end
@spec count_restriction(keyword) :: {:== | :<= | :>=, integer} | {:.., integer, integer}
defp count_restriction(%{times: times}) when is_integer(times) and times >= 0, do: {:==, times}
defp count_restriction(%{times: times}) when is_integer(times) and times < 0 do
raise ArgumentError, "Option `:times` must be a non-negative integer"
end
defp count_restriction(%{min: min, max: max}) when is_integer(min) and is_integer(max) do
cond do
min < 0 or max < 0 ->
raise ArgumentError, "Options `:min` and `:max` must be non-negative integers"
min > max ->
raise ArgumentError, "Option `:min` must be less than or equal to `:max`"
:else ->
{:.., min, max}
end
end
defp count_restriction(%{min: min}) when is_integer(min) and min >= 0, do: {:>=, min}
defp count_restriction(%{max: max}) when is_integer(max) and max >= 0, do: {:<=, max}
defp count_restriction(%{min: min}) when is_integer(min) do
raise ArgumentError, "Option `:min` must be a non-negative integer"
end
defp count_restriction(%{max: max}) when is_integer(max) do
raise ArgumentError, "Option `:max` must be a non-negative integer"
end
defp count_restriction(_opts), do: {:>=, 1}
@spec args_match?(Mock.args(), [any]) :: boolean
defp args_match?(arity, call_args) when is_integer(arity), do: length(call_args) == arity
defp args_match?(args, call_args) when length(args) != length(call_args), do: false
defp args_match?(args, call_args) do
Enum.zip(args, call_args)
|> Enum.all?(fn
{:_, _} -> true
{same_value, same_value} -> true
_else -> false
end)
end
# Not ready for public use
@doc false
@spec get_calls :: [call]
def get_calls do
Process.get(@pd_call_key, [])
end
@doc false
@spec put_call(Operation.t()) :: :ok
def put_call(operation) do
new_call = Operation.get_caller(operation)
all_calls = Process.get(@pd_call_key, [])
updated_calls = [new_call | all_calls]
Process.put(@pd_call_key, updated_calls)
:ok
end
@doc false
@spec assert_call_count(module, atom, Mock.args(), keyword) :: any
def assert_call_count(module, function, args, opts \\ []) do
call_count =
get_calls()
|> Enum.count(fn {m, f, a} ->
module == m and function == f and args_match?(args, a)
end)
Keyword.take(opts, [:times, :min, :max])
|> Enum.into(%{})
|> count_restriction()
|> case do
{:==, times} -> ExUnit.Assertions.assert(call_count == times)
{:>=, min} -> ExUnit.Assertions.assert(call_count >= min)
{:<=, max} -> ExUnit.Assertions.assert(call_count <= max)
{:.., min, max} -> ExUnit.Assertions.assert(call_count in min..max)
end
end
end