Current section
Files
Jump to
Current section
Files
lib/assertions.ex
defmodule Assertions do
@moduledoc """
Helpful assertions with great error messages to help you write better tests.
"""
@type comparison :: (any, any -> boolean)
@doc """
Asserts that two lists contain the same elements without asserting they are
in the same order.
iex> assert_lists_equal([1, 2, 3], [1, 3, 2])
true
"""
@spec assert_lists_equal(list, list) :: true | no_return
defmacro assert_lists_equal(left, right) do
assertion =
assertion(
quote do
assert_lists_equal(unquote(left), unquote(right))
end
)
quote do
{left_diff, right_diff, equal?} = compare_lists(unquote(left), unquote(right), &Kernel.==/2)
if equal? do
true
else
raise ExUnit.AssertionError,
args: [unquote(left), unquote(right)],
left: left_diff,
right: right_diff,
expr: unquote(assertion),
message: "Comparison of each element failed!"
end
end
end
@doc """
Asserts that two lists contain the same elements without asserting they are
in the same order.
The third argument can either be a custom failure message, or a function used
to compare elements in the lists.
iex> assert_lists_equal([1, 2, 3], [1, 3, 2], "NOT A MATCH")
true
iex> assert_lists_equal(["dog"], ["cat"], &(String.length(&1) == String.length(&2)))
true
"""
@spec assert_lists_equal(list, list, comparison | String.t()) :: true | no_return
defmacro assert_lists_equal(left, right, message_or_comparison)
defmacro assert_lists_equal(left, right, message) when is_binary(message) do
assertion =
assertion(
quote do
assert_lists_equal(unquote(left), unquote(right), unquote(message))
end
)
quote do
{left_diff, right_diff, equal?} = compare_lists(unquote(left), unquote(right), &Kernel.==/2)
if equal? do
true
else
raise ExUnit.AssertionError,
args: [unquote(left), unquote(right), unquote(message)],
left: left_diff,
right: right_diff,
expr: unquote(assertion),
message: unquote(message)
end
end
end
defmacro assert_lists_equal(left, right, comparison) do
assertion =
assertion(
quote do
assert_lists_equal(unquote(left), unquote(right), unquote(comparison))
end
)
quote do
{left_diff, right_diff, equal?} =
compare_lists(unquote(left), unquote(right), unquote(comparison))
if equal? do
true
else
raise ExUnit.AssertionError,
args: [unquote(left), unquote(right), unquote(comparison)],
left: left_diff,
right: right_diff,
expr: unquote(assertion),
message: "Comparison of each element failed!"
end
end
end
@doc """
Asserts that two lists contain the same elements without asserting they are
in the same order.
If the comparison fails, the given `message` is used as the failure message.
iex> assert_lists_equal(
iex> ["dog"],
iex> ["cat"],
iex> &(String.length(&1) == String.length(&2)),
iex> "FAILED WITH CUSTOM MESSAGE"
iex> )
true
"""
@spec assert_lists_equal(list, list, comparison, String.t()) :: true | no_return
defmacro assert_lists_equal(left, right, comparison, message) do
assertion =
assertion(
quote do
assert_lists_equal(unquote(left), unquote(right), unquote(comparison), unquote(message))
end
)
quote do
{left_diff, right_diff, equal?} =
compare_lists(unquote(left), unquote(right), unquote(comparison))
if equal? do
true
else
raise ExUnit.AssertionError,
args: [unquote(left), unquote(right), unquote(comparison), unquote(message)],
left: left_diff,
right: right_diff,
expr: unquote(assertion),
message: unquote(message)
end
end
end
@doc """
Asserts that a `map` with the same values for the given `keys` is in the
`list`.
iex> list = [%{first: :first, second: :second, third: :third}]
iex> assert_map_in_list(%{first: :first, second: :second}, list, [:first, :second])
true
"""
@spec assert_map_in_list(map, [map], [any]) :: true | no_return
defmacro assert_map_in_list(map, list, keys) do
assertion =
assertion(
quote do
assert_map_in_list(unquote(map), unquote(list), unquote(keys))
end
)
quote do
keys = unquote(keys)
list = Enum.map(unquote(list), &Map.take(&1, keys))
map = Map.take(unquote(map), keys)
unless map in list do
raise ExUnit.AssertionError,
args: [unquote(map), unquote(list)],
left: map,
right: list,
expr: unquote(assertion),
message: "Map matching the values for keys `#{unquote(stringify_list(keys))}` not found"
else
true
end
end
end
@doc """
Asserts that the values in `left` and `right` are the same for the `keys`
iex> left = %{first: :first, second: :second, third: :third}
iex> right = %{first: :first, second: :second, third: :fourth}
iex> assert_maps_equal(left, right, [:first, :second])
true
"""
@spec assert_maps_equal(map, map, [any]) :: true | no_return
defmacro assert_maps_equal(left, right, keys) do
assertion =
assertion(
quote do
assert_maps_equal(unquote(left), unquote(right), unquote(keys))
end
)
quote do
keys = unquote(keys)
left = Map.take(unquote(left), keys)
right = Map.take(unquote(right), keys)
{left_diff, right_diff, equal?} = compare_maps(left, right)
if equal? do
true
else
raise ExUnit.AssertionError,
args: [unquote(left), unquote(right)],
left: left_diff,
right: right_diff,
expr: unquote(assertion),
message: "Values for #{unquote(stringify_list(keys))} not equal!"
end
end
end
@doc """
Asserts that a struct with certain values is present in the `list`.
There are two ways to make this comparison.
First is to pass a struct, a list of keys to use to compare that struct to
the structs in the list, and a list of structs.
iex> list = [DateTime.utc_now(), Date.utc_today()]
iex> assert_struct_in_list(DateTime.utc_now(), [:year, :month, :day, :second], list)
true
The second way to use this assertion is to pass a map of keys and values that
you expect to be in the struct, a module representing the type of struct you
are expecting, and a list of structs.
iex> list = [DateTime.utc_now(), Date.utc_today()]
iex> year = DateTime.utc_now().year
iex> assert_struct_in_list(%{year: year}, DateTime, list)
true
"""
@spec assert_struct_in_list(struct, [any], [struct]) :: true | no_return
@spec assert_struct_in_list(map, module, [struct]) :: true | no_return
defmacro assert_struct_in_list(struct_or_map, keys_or_type, list)
defmacro assert_struct_in_list(struct, keys, list) when is_list(keys) do
assertion =
assertion(
quote do
assert_struct_in_list(unquote(struct), unquote(keys), unquote(list))
end
)
quote do
keys = [:__struct__ | unquote(keys)]
struct = Map.take(unquote(struct), keys)
list = Enum.map(unquote(list), fn map -> Map.take(map, keys) end)
if struct in list do
true
else
raise ExUnit.AssertionError,
args: [unquote(struct), unquote(keys), unquote(list)],
left: struct,
right: list,
expr: unquote(assertion),
message: "Struct matching the values for keys #{unquote(stringify_list(keys))} not found"
end
end
end
defmacro assert_struct_in_list(map, module, list) do
assertion =
assertion(
quote do
assert_struct_in_list(unquote(map), unquote(module), unquote(list))
end
)
quote do
map = Map.put(unquote(map), :__struct__, unquote(module))
keys = Map.keys(map)
list = Enum.map(unquote(list), fn map -> Map.take(map, keys) end)
if map in list do
true
else
raise ExUnit.AssertionError,
args: [unquote(map), unquote(module), unquote(list)],
left: map,
right: list,
expr: unquote(assertion),
message: "Struct matching #{inspect(map)} not found"
end
end
end
@doc """
Asserts that the values in struct `left` and struct `right` are the same for
the given `keys`
iex> assert_structs_equal(DateTime.utc_now(), DateTime.utc_now(), [:year, :minute])
true
"""
@spec assert_structs_equal(struct, struct, [any]) :: true | no_return
defmacro assert_structs_equal(left, right, keys) do
assertion =
assertion(
quote do
assert_structs_equal(unquote(left), unquote(right), unquote(keys))
end
)
quote do
keys = [:__struct__ | unquote(keys)]
left = Map.take(unquote(left), keys)
right = Map.take(unquote(right), keys)
{left_diff, right_diff, equal?} = compare_maps(left, right)
if equal? do
true
else
raise ExUnit.AssertionError,
args: [unquote(left), unquote(right)],
left: left_diff,
right: right_diff,
expr: unquote(assertion),
message: "Values for #{unquote(stringify_list(keys))} not equal!"
end
end
end
@doc """
Asserts that all maps, structs or keyword lists in `list` have the same
`value` for `key`.
iex> assert_all_have_value([%{key: :value}, %{key: :value, other: :key}], :key, :value)
true
iex> assert_all_have_value([[key: :value], [key: :value, other: :key]], :key, :value)
true
iex> assert_all_have_value([[key: :value], %{key: :value, other: :key}], :key, :value)
true
"""
@spec assert_all_have_value(list(map | struct | Keyword.t()), any, any) :: true | no_return
defmacro assert_all_have_value(list, key, value) do
assertion =
assertion(
quote do
assert_all_have_value(unquote(list), unquote(key), unquote(value))
end
)
quote do
key = unquote(key)
value = unquote(value)
list =
Enum.map(unquote(list), fn
map when is_map(map) -> Map.take(map, [key])
list -> [{key, Keyword.get(list, key, :key_not_present)}]
end)
diff =
Enum.reject(list, fn
map when is_map(map) -> Map.equal?(map, %{key => value})
list -> Keyword.equal?(list, [{key, value}])
end)
if diff == [] do
true
else
raise ExUnit.AssertionError,
args: [unquote(list), unquote(key), unquote(value)],
left: %{key => value},
right: diff,
expr: unquote(assertion),
message: "Values for `#{inspect(key)}` not equal in all elements!"
end
end
end
@doc """
Asserts that the file at `path` is changed to match `comparison` after
executing `expr`.
If the file matches `comparison` before executing `expr`, this assertion will
fail. The file does not have to exist before executing `expr` in order for
this assertion to pass.
iex> path = Path.expand("../tmp/file.txt", __DIR__)
iex> result = assert_changes_file(path, "hi") do
iex> File.mkdir_p!(Path.dirname(path))
iex> File.write(path, "hi")
iex> end
iex> File.rm_rf!(Path.dirname(path))
iex> result
true
"""
@spec assert_changes_file(Path.t(), String.t() | Regex.t(), Macro.expr()) :: true | no_return
defmacro assert_changes_file(path, comparison, [do: expr] = full) do
assertion =
assertion(
quote do
assert_changes_file(unquote(path), unquote(comparison), unquote(full))
end
)
quote do
path = unquote(path)
comparison = unquote(comparison)
args = [unquote(path), unquote(comparison)]
{match_before?, start_file} =
case File.read(path) do
{:ok, start_file} -> {start_file =~ comparison, start_file}
_ -> {false, nil}
end
if match_before? do
raise ExUnit.AssertionError,
args: args,
expr: unquote(assertion),
left: start_file,
right: unquote(comparison),
message: "File #{inspect(path)} matched `#{inspect(comparison)}` before executing expr!"
else
unquote(expr)
end_file =
case File.read(path) do
{:ok, end_file} ->
end_file
_ ->
raise ExUnit.AssertionError,
args: args,
expr: unquote(assertion),
message: "File #{inspect(path)} does not exist after executing expr!"
end
if end_file =~ comparison do
true
else
raise ExUnit.AssertionError,
args: args,
left: end_file,
right: comparison,
expr: unquote(assertion),
message: "File did not change to match comparison after expr!"
end
end
end
end
@doc """
Asserts that the file at `path` is created after executing `expr`.
iex> path = Path.expand("../tmp/file.txt", __DIR__)
iex> File.mkdir_p!(Path.dirname(path))
iex> result = assert_creates_file path do
iex> File.write(path, "hi")
iex> end
iex> File.rm_rf!(Path.dirname(path))
iex> result
true
"""
@spec assert_creates_file(Path.t(), Macro.expr()) :: true | no_return
defmacro assert_creates_file(path, [do: expr] = full) do
assertion =
assertion(
quote do
assert_creates_file(unquote(path), unquote(full))
end
)
quote do
path = unquote(path)
args = [unquote(path)]
if File.exists?(path) do
raise ExUnit.AssertionError,
args: args,
expr: unquote(assertion),
message: "File #{inspect(path)} existed before executing expr!"
else
unquote(expr)
if File.exists?(path) do
true
else
raise ExUnit.AssertionError,
args: args,
expr: unquote(assertion),
message: "File #{inspect(path)} does not exist after executing expr!"
end
end
end
end
@doc """
Asserts that the file at `path` is deleted after executing `expr`.
iex> path = Path.expand("../tmp/file.txt", __DIR__)
iex> File.mkdir_p!(Path.dirname(path))
iex> File.write(path, "hi")
iex> assert_deletes_file path do
iex> File.rm_rf!(Path.dirname(path))
iex> end
true
"""
@spec assert_deletes_file(Path.t(), Macro.expr()) :: true | no_return
defmacro assert_deletes_file(path, [do: expr] = full) do
assertion =
assertion(
quote do
assert_deletes_file(unquote(path), unquote(full))
end
)
quote do
path = unquote(path)
args = [unquote(path)]
if !File.exists?(path) do
raise ExUnit.AssertionError,
args: args,
expr: unquote(assertion),
message: "File #{inspect(path)} did not exist before executing expr!"
else
unquote(expr)
if !File.exists?(path) do
true
else
raise ExUnit.AssertionError,
args: args,
expr: unquote(assertion),
message: "File #{inspect(path)} exists after executing expr!"
end
end
end
end
@doc """
Tests that a message matching the given `pattern`, and only that message, is
received before the given `timeout`, specified in milliseconds.
The optional second argument is a timeout for the `receive` to wait for the
expected message, and defaults to 100ms.
If you want to check that no message was received before the expected message,
**and** that no message is received for a given time after calling
`receive_only?/2`, you can combine `received_only?/2` with
`ExUnit.Assertions.refute_receive/3`.
assert_receive_only(:hello)
refute_receive _, 100
## Examples
iex> send(self(), :hello)
iex> assert_receive_only(:hello)
true
iex> send(self(), [:hello])
iex> assert_receive_only([_])
true
iex> a = :hello
iex> send(self(), :hello)
iex> assert_receive_only(^a)
true
iex> send(self(), :hello)
iex> assert_receive_only(a when is_atom(a))
iex> a
:hello
iex> send(self(), %{key: :value})
iex> assert_receive_only(%{key: value} when is_atom(value))
iex> value
:value
If a message is received after the function has matched a message to the given
pattern, but the second message is received before the timeout, that second
message is ignored and the function returns `true`.
This assertion only tests that the message that matches the given pattern was
the first message in the process inbox, and that nothing was sent between the
sending the message that matches the pattern and when `receive_only?/2` was
called.
iex> Process.send_after(self(), :hello, 20)
iex> Process.send_after(self(), :hello_again, 50)
iex> assert_receive_only(:hello, 100)
true
"""
@spec assert_receive_only(Macro.expr(), non_neg_integer) :: any | no_return
defmacro assert_receive_only(pattern, timeout \\ 100) do
binary = Macro.to_string(pattern)
assertion =
assertion(
quote do
assert_receive_only(unquote(pattern), unquote(timeout))
end
)
caller = __CALLER__
# Expand before extracting metadata
pattern = expand_pattern(pattern, caller)
vars = collect_vars_from_pattern(pattern)
pins = collect_pins_from_pattern(pattern, Macro.Env.vars(caller))
pattern =
case pattern do
{:when, meta, [left, right]} ->
{:when, meta, [quote(do: unquote(left) = received), right]}
left ->
quote(do: unquote(left) = received)
end
quoted_pattern =
quote do
case message do
unquote(pattern) ->
_ = unquote(vars)
true
_ ->
false
end
end
pattern_finder =
quote do
fn message ->
unquote(suppress_warning(quoted_pattern))
end
end
timeout =
if is_integer(timeout) do
timeout
else
quote do: ExUnit.Assertions.__timeout__(unquote(timeout))
end
failure_message =
quote do
ExUnit.Assertions.__timeout__(
unquote(binary),
unquote(pins),
unquote(pattern_finder),
timeout
)
end
bind_variables =
quote do
{received, unquote(vars)}
end
quote do
timeout = unquote(timeout)
unquote(bind_variables) =
receive do
unquote(pattern) ->
result = unquote(bind_variables)
receive do
thing ->
raise ExUnit.AssertionError,
expr: unquote(assertion),
message: "`#{inspect(thing)}` was also in the mailbox"
after
0 ->
result
end
random_thing ->
raise ExUnit.AssertionError,
expr: unquote(assertion),
message: "Received unexpected message: `#{inspect(random_thing)}`"
after
timeout -> flunk(unquote(failure_message))
end
true
end
end
@doc false
def compare_maps(left, right) do
{left_diff, right_diff, equal?} =
compare_lists(Map.to_list(left), Map.to_list(right), &Kernel.==/2)
{Map.new(left_diff), Map.new(right_diff), equal?}
end
@doc false
def compare_lists(left, right, comparison)
when is_function(comparison, 2) and is_list(left) and is_list(right) do
left_diff = compare(right, left, comparison)
right_diff = compare(left, right, comparison)
{left_diff, right_diff, left_diff == right_diff}
end
def compare_lists(left, right, comparison) do
quote do
left = unquote(left)
right = unquote(right)
comparison = unquote(comparison)
left_diff = compare(right, left, comparison)
right_diff = compare(left, right, comparison)
{left_diff, right_diff, left_diff == right_diff}
end
end
defp compare(left, right, comparison) do
Enum.reduce(left, right, fn left_element, list ->
case Enum.find_index(list, &comparison.(left_element, &1)) do
nil -> list
index -> List.delete_at(list, index)
end
end)
end
defp assertion(quoted), do: Macro.escape(quoted, prune_metadata: true)
defp stringify_list(list) do
quote do
unquote(list)
|> Enum.map(fn
elem when is_atom(elem) -> ":#{elem}"
elem when is_binary(elem) -> "\"#{elem}\""
elem -> "#{inspect(elem)}"
end)
|> Enum.join(", ")
end
end
defp expand_pattern({:when, meta, [left, right]}, caller) do
left = expand_pattern_except_vars(left, Macro.Env.to_match(caller))
right = expand_pattern_except_vars(right, %{caller | context: :guard})
{:when, meta, [left, right]}
end
defp expand_pattern(expr, caller) do
expand_pattern_except_vars(expr, Macro.Env.to_match(caller))
end
defp expand_pattern_except_vars(expr, caller) do
Macro.prewalk(expr, fn
{var, _, context} = node when is_atom(var) and is_atom(context) -> node
other -> Macro.expand(other, caller)
end)
end
defp collect_vars_from_pattern(expr) do
Macro.prewalk(expr, [], fn
{:::, _, [left, _]}, acc ->
{[left], acc}
{skip, _, [_]}, acc when skip in [:^, :@] ->
{:ok, acc}
{:_, _, context}, acc when is_atom(context) ->
{:ok, acc}
{name, meta, context}, acc when is_atom(name) and is_atom(context) ->
{:ok, [{name, [generated: true] ++ meta, context} | acc]}
node, acc ->
{node, acc}
end)
|> elem(1)
end
defp collect_pins_from_pattern(expr, vars) do
{_, pins} =
Macro.prewalk(expr, [], fn
{:^, _, [{name, _, nil} = var]}, acc ->
if {name, nil} in vars do
{:ok, [{name, var} | acc]}
else
{:ok, acc}
end
form, acc ->
{form, acc}
end)
Enum.uniq_by(pins, &elem(&1, 0))
end
defp suppress_warning({name, meta, [expr, [do: clauses]]}) do
clauses =
Enum.map(clauses, fn {:->, meta, args} ->
{:->, [generated: true] ++ meta, args}
end)
{name, meta, [expr, [do: clauses]]}
end
end