defmodule Prove do
@moduledoc """
Prove provides the macros `prove` and `batch` to write simple tests in `ExUnit`
shorter.
A `prove` is just helpful for elementary tests. Prove generates one test with
one assert for every `prove`.
The disadvantage of these macros is that the tests are containing fewer
descriptions. For this reason and also if a `prove` looks too complicated, a
regular `test` is to prefer.
## Example
```elixir
defmodule NumTest do
use ExUnit.Case
import Prove
defmodule Num do
def check(0), do: :zero
def check(x) when is_integer(x) do
case rem(x, 2) do
0 -> :even
1 -> :odd
end
end
def check(_), do: :error
end
describe "check/1" do
prove Num.check(0) == :zero
batch "returns :odd or :even" do
prove Num.check(1) == :odd
prove Num.check(2) == :even
prove "for big num", Num.check(2_000) == :even
end
batch "returns :error" do
prove Num.check("1") == :error
prove Num.check(nil) == :error
end
end
end
```
The example above generates the following tests:
```shell
$> mix test test/num_test.exs --trace --seed 0
NumTest [test/num_test.exs]
* prove check/1 (1) (0.00ms) [L#20]
* prove check/1 returns :odd or :even (1) (0.00ms) [L#23]
* prove check/1 returns :odd or :even (2) (0.00ms) [L#24]
* prove check/1 returns :odd or :even for big num (1) (0.00ms) [L#25]
* prove check/1 returns :error (1) (0.00ms) [L#29]
* prove check/1 returns :error (2) (0.00ms) [L#30]
Finished in 0.08 seconds (0.00s async, 0.08s sync)
6 proves, 0 failures
Randomized with seed 0
```
The benefit of `prove` is that tests with multiple asserts can be avoided.
The example above with regular `test`s:
```elixir
...
describe "check/1" do
test "returns :zero" do
assert Num.check(0) == :zero
end
test "returns :odd or :even" do
assert Num.check(1) == :odd
assert Num.check(2) == :even
assert Num.check(2_000) == :even
end
test "returns :error" do
assert Num.check("1") == :error
assert Num.check(nil) == :error
end
end
...
```
```shell
$> mix test test/num_test.exs --trace --seed 0
NumTest [test/num_test.exs]
* test check/1 returns :zero (0.00ms) [L#36]
* test check/1 returns :odd or :even (0.00ms) [L#40]
* test check/1 returns :error (0.00ms) [L#46]
Finished in 0.03 seconds (0.00s async, 0.03s sync)
3 tests, 0 failures
Randomized with seed 0
```
"""
@operator [:==, :<, :>, :<=, :>=, :===, :=~, :!==, :!=, :in]
@doc """
A macro to write simple a simple test shorter.
Code like:
```elxir
prove identity(5) == 5
prove identity(6) > 5
prove "check:", identity(7) == 7
```
is equivalent to:
```elixir
test "(1)" do
assert identity(5) == 5
end
test "(2)" do
assert identity(6) > 5
end
test "check: (1)" do
assert identity(7) == 7
end
```
`prove` supports the operators `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, `>=`,
and `=~`.
"""
defmacro prove(description \\ "", expr)
defmacro prove(description, {operator, _, [_, _]} = expr)
when is_binary(description) and operator in @operator do
quote_prove(
update_description(description, __CALLER__),
expr,
__CALLER__
)
end
defmacro prove(_description, expr) do
raise ArgumentError, message: "Unsupported do: #{Macro.to_string(expr)}"
end
@doc """
Creates a batch of proves.
A batch adds the `description` to every `prove`. This can be used to
group`proves`s with a context. Every prove is still an own `test`.
Code like:
```
batch "valid" do
prove 1 == 1
prove "really", 2 == 2
end
```
is equivalent to:
```
test "valid (1)" do
assert 1 == 1
end
test "valid really (1)" do
assert 2 == 2
end
```
"""
defmacro batch(description, do: {:__block__, _meta, block}) do
{:__block__, [], quote_block(description, block, __CALLER__)}
end
defmacro batch(description, do: block) when is_tuple(block) do
{:__block__, [], quote_block(description, [block], __CALLER__)}
end
defp quote_block(description, block, caller) do
Enum.map(block, fn
{:prove, meta, [op]} ->
quote_block_prove(description, op, meta)
{:prove, meta, [prove_description, op]} ->
quote_block_prove("#{description} #{prove_description}", op, meta)
_error ->
raise CompileError,
file: caller.file,
line: caller.line,
description: "A batch can only contain prove/1/2 functions"
end)
end
defp quote_block_prove(description, op, meta) do
{marker, _meta, children} =
quote do
prove unquote(description), unquote(op)
end
{marker, meta, children}
end
defp quote_prove(
description,
{operator, _meta, [_, _]} = expr,
%{module: mod, file: file, line: line}
)
when is_binary(description) and operator in @operator do
assertion = quote_assertion(expr)
quote generated: true,
file: file,
line: line,
bind_quoted: [
assertion: Macro.escape(assertion),
description: description,
file: file,
line: line,
mod: mod
] do
name = ExUnit.Case.register_test(mod, file, line, :prove, description, [])
def unquote(name)(_) do
unquote(assertion)
rescue
error in [ExUnit.AssertionError] ->
reraise(error, __STACKTRACE__)
end
end
end
defp quote_assertion({operator, _meta, [left, right]} = expr) do
expr = Macro.escape(expr)
quote do
left = unquote(left)
right = unquote(right)
ExUnit.Assertions.assert(Kernel.apply(Kernel, unquote(operator), [left, right]),
left: left,
right: right,
expr: unquote(expr),
message: "Prove with #{to_string(unquote(operator))} failed"
)
end
end
defp update_description(description, caller) do
case Module.get_attribute(caller.module, :prove_counter) do
nil ->
Module.register_attribute(caller.module, :count, persist: false)
Module.put_attribute(caller.module, :prove_counter, Map.put(%{}, description, 1))
join(description, 1)
%{^description => value} = map ->
inc = value + 1
Module.put_attribute(caller.module, :prove_counter, Map.put(map, description, inc))
join(description, inc)
map ->
Module.put_attribute(caller.module, :prove_counter, Map.put(map, description, 1))
join(description, 1)
end
end
defp join("", b), do: "(#{b})"
defp join(a, b), do: "#{a} (#{b})"
end