diff --git a/lib/ex_unit/lib/ex_unit/assertions.ex b/lib/ex_unit/lib/ex_unit/assertions.ex index ed746ec0ce..859fe3ecd1 100644 --- a/lib/ex_unit/lib/ex_unit/assertions.ex +++ b/lib/ex_unit/lib/ex_unit/assertions.ex @@ -19,7 +19,8 @@ defmodule ExUnit.AssertionError do expr: any, args: any, doctest: any, - context: any + context: any, + error_context: any } defexception left: @no_value, @@ -28,7 +29,8 @@ defmodule ExUnit.AssertionError do expr: @no_value, args: @no_value, doctest: @no_value, - context: :== + context: :==, + error_context: @no_value @doc """ Indicates no meaningful value for a field. @@ -146,62 +148,8 @@ defmodule ExUnit.Assertions do assert match?([%{id: id} | _] when is_integer(id), records) """ - defmacro assert({:=, meta, [left, right]} = assertion) do - code = escape_quoted(:assert, meta, mark_as_generated(assertion)) - - check = - quote generated: true do - if right do - :ok - else - raise ExUnit.AssertionError, - expr: expr, - message: "Expected truthy, got #{inspect(right)}" - end - end - - {left, right} = move_match(left, right) - __match__(left, right, code, check, __CALLER__) - end - - defmacro assert({:match?, meta, [left, right]} = assertion) do - code = escape_quoted(:assert, meta, mark_as_generated(assertion)) - match? = {:match?, meta, [left, Macro.var(:right, __MODULE__)]} - - left = __expand_pattern__(left, __CALLER__) - pins = collect_pins_from_pattern(left, Macro.Env.vars(__CALLER__)) - - quote do - right = unquote(right) - left = unquote(Macro.escape(left)) - - ExUnit.Assertions.assert(unquote(match?), - right: right, - left: left, - expr: unquote(code), - message: "match (match?) failed" <> ExUnit.Assertions.__pins__(unquote(pins)), - context: {:match, unquote(pins)} - ) - end - end - defmacro assert(assertion) do - if translated = translate_assertion(:assert, assertion, __CALLER__) do - translated - else - {args, value} = extract_args(assertion, __CALLER__) - - quote generated: true do - if value = unquote(value) do - value - else - raise ExUnit.AssertionError, - args: unquote(args), - expr: unquote(escape_quoted(:assert, [], assertion)), - message: "Expected truthy, got #{inspect(value)}" - end - end - end + build_assertion(:assert, assertion, [diff: true], __CALLER__) end @doc """ @@ -224,52 +172,168 @@ defmodule ExUnit.Assertions do refute age < 0 """ - defmacro refute({:match?, meta, [left, right]} = assertion) do - code = escape_quoted(:refute, meta, assertion) - match? = {:match?, meta, [left, Macro.var(:right, __MODULE__)]} + defmacro refute(assertion) do + build_assertion(:refute, assertion, [diff: true], __CALLER__) + end - left = __expand_pattern__(left, __CALLER__) - pins = collect_pins_from_pattern(left, Macro.Env.vars(__CALLER__)) + ## START HELPERS - quote do - right = unquote(right) - left = unquote(Macro.escape(left)) + @operator [:==, :<, :>, :<=, :>=, :===, :=~, :!==, :!=, :in] - refute unquote(match?), - right: right, - left: left, - expr: unquote(code), - message: - "match (match?) succeeded, but should have failed" <> - ExUnit.Assertions.__pins__(unquote(pins)), - context: {:match, unquote(pins)} + defp build_assertion(kind, assertion, opts_expr, caller) do + case {kind, assertion} do + {:assert, {:=, meta, [left, right]} = assertion} -> + code = escape_quoted(:assert, meta, mark_as_generated(assertion)) + + check = + quote generated: true do + if right do + :ok + else + ExUnit.Assertions.__raise_assertion_error__( + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts_expr), + expr: expr, + message: "Expected truthy, got #{inspect(right)}" + ) + ) + end + end + + {left, right} = move_match(left, right) + __match__(left, right, code, check, opts_expr, caller) + + {:assert, {:match?, meta, [left, right]} = assertion} -> + code = escape_quoted(:assert, meta, mark_as_generated(assertion)) + match? = {:match?, meta, [left, Macro.var(:right, __MODULE__)]} + + left = __expand_pattern__(left, caller) + pins = collect_pins_from_pattern(left, Macro.Env.vars(caller)) + + quote do + right = unquote(right) + left = unquote(Macro.escape(left)) + + ExUnit.Assertions.__assert__( + unquote(match?), + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts_expr), + right: right, + left: left, + expr: unquote(code), + message: "match (match?) failed" <> ExUnit.Assertions.__pins__(unquote(pins)), + context: {:match, unquote(pins)} + ) + ) + end + + {:refute, {:match?, meta, [left, right]} = assertion} -> + code = escape_quoted(:refute, meta, assertion) + match? = {:match?, meta, [left, Macro.var(:right, __MODULE__)]} + + left = __expand_pattern__(left, caller) + pins = collect_pins_from_pattern(left, Macro.Env.vars(caller)) + + quote do + right = unquote(right) + left = unquote(Macro.escape(left)) + + if value = unquote(match?) do + ExUnit.Assertions.__raise_assertion_error__( + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts_expr), + right: right, + left: left, + expr: unquote(code), + message: + "match (match?) succeeded, but should have failed" <> + ExUnit.Assertions.__pins__(unquote(pins)), + context: {:match, unquote(pins)} + ) + ) + else + value + end + end + + _ -> + case translate_assertion(kind, assertion, opts_expr, caller) do + nil -> build_truthy_assertion(kind, assertion, opts_expr, caller) + translated when kind == :refute -> {:!, [generated: true], [translated]} + translated -> translated + end end end - defmacro refute(assertion) do - if translated = translate_assertion(:refute, assertion, __CALLER__) do - {:!, [generated: true], [translated]} - else - {args, value} = extract_args(assertion, __CALLER__) + defp build_runtime_assertion(:assert, assertion, message_or_opts) do + quote generated: true do + value = unquote(assertion) + + ExUnit.Assertions.__assert__( + value, + ExUnit.Assertions.__normalize_assert_opts__(unquote(message_or_opts)) + ) + + true + end + end + + defp build_runtime_assertion(:refute, assertion, message_or_opts) do + quote generated: true do + value = unquote(assertion) + + ExUnit.Assertions.__assert__( + !value, + ExUnit.Assertions.__normalize_assert_opts__(unquote(message_or_opts)) + ) + + false + end + end + + defp build_truthy_assertion(:assert, assertion, opts, caller) do + {args, value} = extract_args(assertion, caller) - quote generated: true do - if value = unquote(value) do - raise ExUnit.AssertionError, + quote generated: true do + if value = unquote(value) do + value + else + ExUnit.Assertions.__raise_assertion_error__( + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts), + args: unquote(args), + expr: unquote(escape_quoted(:assert, [], assertion)), + message: "Expected truthy, got #{inspect(value)}" + ) + ) + end + end + end + + defp build_truthy_assertion(:refute, assertion, opts, caller) do + {args, value} = extract_args(assertion, caller) + + quote generated: true do + if value = unquote(value) do + ExUnit.Assertions.__raise_assertion_error__( + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts), args: unquote(args), expr: unquote(escape_quoted(:refute, [], assertion)), message: "Expected false or nil, got #{inspect(value)}" - else - value - end + ) + ) + else + value end end end - ## START HELPERS + defp diff_enabled?(opts) when is_list(opts) do + Keyword.keyword?(opts) and + (Keyword.get(opts, :diff, false) or Keyword.has_key?(opts, :error_context)) + end - @operator [:==, :<, :>, :<=, :>=, :===, :=~, :!==, :!=, :in] + defp diff_enabled?(_opts) do + false + end - defp translate_assertion(:assert, {operator, meta, [_, _]} = expr, caller) + defp translate_assertion(:assert, {operator, meta, [_, _]} = expr, opts, caller) when operator in @operator do if match?([{_, Kernel}], Macro.Env.lookup_import(caller, {operator, 2})) do left = Macro.var(:left, __MODULE__) @@ -277,11 +341,11 @@ defmodule ExUnit.Assertions do call = {operator, meta, [left, right]} equality_check? = operator in [:<, :>, :!==, :!=] message = "Assertion with #{operator} failed" - translate_operator(:assert, expr, call, message, equality_check?, caller) + translate_operator(:assert, expr, call, message, equality_check?, opts, caller) end end - defp translate_assertion(:refute, {operator, meta, [_, _]} = expr, caller) + defp translate_assertion(:refute, {operator, meta, [_, _]} = expr, opts, caller) when operator in @operator do if match?([{_, Kernel}], Macro.Env.lookup_import(caller, {operator, 2})) do left = Macro.var(:left, __MODULE__) @@ -289,15 +353,23 @@ defmodule ExUnit.Assertions do call = {:not, meta, [{operator, meta, [left, right]}]} equality_check? = operator in [:<=, :>=, :===, :==, :=~] message = "Refute with #{operator} failed" - translate_operator(:refute, expr, call, message, equality_check?, caller) + translate_operator(:refute, expr, call, message, equality_check?, opts, caller) end end - defp translate_assertion(_kind, _expected, _caller) do + defp translate_assertion(_kind, _expected, _opts, _caller) do nil end - defp translate_operator(kind, {op, meta, [left, right]} = expr, call, message, true, _caller) do + defp translate_operator( + kind, + {op, meta, [left, right]} = expr, + call, + message, + true, + opts, + _caller + ) do expr = escape_quoted(kind, meta, expr) context = if op in [:===, :!==], do: :===, else: :== @@ -308,24 +380,37 @@ defmodule ExUnit.Assertions do message = unquote(message) if ExUnit.Assertions.__equal__?(left, right) do - ExUnit.Assertions.assert(false, - left: left, - expr: expr, - message: message <> ", both sides are exactly equal" + ExUnit.Assertions.__raise_assertion_error__( + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts), + left: left, + expr: expr, + message: message <> ", both sides are exactly equal" + ) ) else - ExUnit.Assertions.assert(unquote(call), - left: left, - right: right, - expr: expr, - message: message, - context: unquote(context) + ExUnit.Assertions.__assert__( + unquote(call), + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts), + left: left, + right: right, + expr: expr, + message: message, + context: unquote(context) + ) ) end end end - defp translate_operator(kind, {op, meta, [left, right]} = expr, call, message, false, _caller) do + defp translate_operator( + kind, + {op, meta, [left, right]} = expr, + call, + message, + false, + opts, + _caller + ) do expr = escape_quoted(kind, meta, expr) context = if op in [:===, :!==], do: :===, else: :== @@ -333,16 +418,44 @@ defmodule ExUnit.Assertions do left = unquote(left) right = unquote(right) - ExUnit.Assertions.assert(unquote(call), - left: left, - right: right, - expr: unquote(expr), - message: unquote(message), - context: unquote(context) + ExUnit.Assertions.__assert__( + unquote(call), + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts), + left: left, + right: right, + expr: unquote(expr), + message: unquote(message), + context: unquote(context) + ) ) end end + @doc false + def __assert__(value, opts) when is_list(opts) do + if !value, do: __raise_assertion_error__(opts) + value + end + + @doc false + def __raise_assertion_error__(opts) when is_list(opts) do + raise ExUnit.AssertionError, Keyword.delete(opts, :diff) + end + + @doc false + def __merge_assertion_opts__(opts, defaults) when is_list(opts) do + Keyword.merge(defaults, opts) + end + + @doc false + def __normalize_assert_opts__(message) when is_binary(message) do + [message: message] + end + + def __normalize_assert_opts__(opts) when is_list(opts) do + opts + end + @doc false def __equal__?(left, right) do left === right @@ -389,23 +502,18 @@ defmodule ExUnit.Assertions do @doc false def __match__({:when, _, _} = left, right, _, _, _) do - suggestion = - quote do - assert match?(unquote(left), unquote(right)) - end - - raise ArgumentError, """ - invalid pattern in assert/1: - - #{Macro.to_string(left) |> Inspect.Error.pad(2)} + raise_invalid_assert_match!(left, right) + end - To assert with guards, use match?/2: + def __match__(left, right, code, check, caller) do + __match__(left, right, code, check, [], caller) + end - #{Macro.to_string(suggestion) |> Inspect.Error.pad(2)} - """ + def __match__({:when, _, _} = left, right, _, _, _, _) do + raise_invalid_assert_match!(left, right) end - def __match__(left, right, code, check, caller) do + def __match__(left, right, code, check, opts, caller) do left = __expand_pattern__(left, caller) vars = collect_vars_from_pattern(left) pins = collect_pins_from_pattern(left, Macro.Env.vars(caller)) @@ -421,12 +529,15 @@ defmodule ExUnit.Assertions do _ -> left = unquote(Macro.escape(left)) - raise ExUnit.AssertionError, - left: left, - right: right, - expr: expr, - message: "match (=) failed" <> ExUnit.Assertions.__pins__(unquote(pins)), - context: {:match, unquote(pins)} + ExUnit.Assertions.__raise_assertion_error__( + ExUnit.Assertions.__merge_assertion_opts__(unquote(opts), + left: left, + right: right, + expr: expr, + message: "match (=) failed" <> ExUnit.Assertions.__pins__(unquote(pins)), + context: {:match, unquote(pins)} + ) + ) end end ) @@ -439,10 +550,42 @@ defmodule ExUnit.Assertions do end end + defp raise_invalid_assert_match!(left, right) do + suggestion = + quote do + assert match?(unquote(left), unquote(right)) + end + + raise ArgumentError, """ + invalid pattern in assert/1: + + #{Macro.to_string(left) |> Inspect.Error.pad(2)} + + To assert with guards, use match?/2: + + #{Macro.to_string(suggestion) |> Inspect.Error.pad(2)} + """ + end + ## END HELPERS @doc """ - Asserts `value` is truthy, displaying the given `message` otherwise. + Asserts `value` is truthy. + + A message or keyword list of options may be given as a second argument. + To enable the same expression introspection as `assert/1`, the options + must be passed directly as a keyword list. + + ## Options + + * `:message` - customizes the failure message + + * `:diff` - when `true`, enables the same expression introspection and + diffing as `assert/1` + + * `:error_context` - includes the given value under `context:` in the + failure output. Binaries are shown as-is and other values are formatted + with `inspect/1`. This option implies `diff: true` ## Examples @@ -452,14 +595,22 @@ defmodule ExUnit.Assertions do assert match?({:ok, _}, x), "expected x to match {:ok, _}" - """ - def assert(value, message) when is_binary(message) do - assert(value, message: message) - end + assert x == :foo, message: "expected x to be foo", diff: true - def assert(value, opts) when is_list(opts) do - if !value, do: raise(ExUnit.AssertionError, opts) - true + assert x == :foo, error_context: %{id: 1} + + """ + defmacro assert(assertion, message_or_opts) do + if diff_enabled?(message_or_opts) do + build_assertion( + :assert, + assertion, + quote(do: ExUnit.Assertions.__normalize_assert_opts__(unquote(message_or_opts))), + __CALLER__ + ) + else + build_runtime_assertion(:assert, assertion, message_or_opts) + end end @doc """ @@ -994,13 +1145,41 @@ defmodule ExUnit.Assertions do @doc """ Asserts `value` is `nil` or `false` (that is, `value` is not truthy). + A message or keyword list of options may be given as a second argument. + To enable the same expression introspection as `refute/1`, the options + must be passed directly as a keyword list. + + ## Options + + * `:message` - customizes the failure message + + * `:diff` - when `true`, enables the same expression introspection and + diffing as `refute/1` + + * `:error_context` - includes the given value under `context:` in the + failure output. Binaries are shown as-is and other values are formatted + with `inspect/1`. This option implies `diff: true` + ## Examples refute true, "This will obviously fail" + refute x == :foo, message: "expected x not to be foo", diff: true + + refute x == :foo, error_context: %{id: 1} + """ - def refute(value, message) do - not assert(!value, message) + defmacro refute(assertion, message_or_opts) do + if diff_enabled?(message_or_opts) do + build_assertion( + :refute, + assertion, + quote(do: ExUnit.Assertions.__normalize_assert_opts__(unquote(message_or_opts))), + __CALLER__ + ) + else + build_runtime_assertion(:refute, assertion, message_or_opts) + end end @doc """ diff --git a/lib/ex_unit/lib/ex_unit/formatter.ex b/lib/ex_unit/lib/ex_unit/formatter.ex index 43c10d146f..08245512f2 100644 --- a/lib/ex_unit/lib/ex_unit/formatter.ex +++ b/lib/ex_unit/lib/ex_unit/formatter.ex @@ -375,7 +375,10 @@ defmodule ExUnit.Formatter do defp linked_or_trapped_exit(_kind, _reason), do: :error defp format_exception(test, %ExUnit.AssertionError{} = struct, stack, width, formatter, pad) do - label_padding_size = if has_value?(struct.right), do: 7, else: 6 + label_padding_size = + if(has_value?(struct.right), do: 7, else: 6) + |> max(if has_value?(struct.error_context), do: 9, else: 0) + padding_size = label_padding_size + byte_size(@counter_padding) code_multiline = @@ -388,7 +391,8 @@ defmodule ExUnit.Formatter do message: if_value(struct.message, &format_message(&1, formatter)), doctest: if_value(struct.doctest, &pad_multiline(&1, 2 + byte_size(@counter_padding))), code: if_value(struct.expr, code_multiline, fn -> get_code(test, stack) || @no_value end), - arguments: if_value(struct.args, &format_args(&1, width)) + arguments: if_value(struct.args, &format_args(&1, width)), + context: if_value(struct.error_context, &format_error_context(&1, padding_size, width)) ] |> Kernel.++(format_assertion_diff(struct, padding_size, width, formatter)) |> format_meta(formatter, pad, label_padding_size) @@ -643,6 +647,14 @@ defmodule ExUnit.Formatter do end end + defp format_error_context(value, padding_size, _width) when is_binary(value) do + pad_multiline(value, padding_size) + end + + defp format_error_context(value, padding_size, width) do + inspect_multiline(value, padding_size, width) + end + defp format_args(args, width) do entries = for {arg, i} <- Enum.with_index(args, 1) do diff --git a/lib/ex_unit/test/ex_unit/assertions_test.exs b/lib/ex_unit/test/ex_unit/assertions_test.exs index 52192206df..a40fcf3eda 100644 --- a/lib/ex_unit/test/ex_unit/assertions_test.exs +++ b/lib/ex_unit/test/ex_unit/assertions_test.exs @@ -99,6 +99,45 @@ defmodule ExUnit.AssertionsTest do end end + test "assert with error_context implies diff" do + try do + assert 1 + 1 == 1, error_context: %{id: 1} + flunk("This should never be tested") + rescue + error in [ExUnit.AssertionError] -> + 2 = error.left + 1 = error.right + %{id: 1} = error.error_context + "Assertion with == failed" = error.message + assert Exception.message(error) =~ "context: %{id: 1}" + end + end + + test "assert with diff and custom message keeps diff details" do + try do + assert 1 + 1 == 1, diff: true, message: "numbers did not match", error_context: "item 4" + flunk("This should never be tested") + rescue + error in [ExUnit.AssertionError] -> + 2 = error.left + 1 = error.right + "numbers did not match" = error.message + "item 4" = error.error_context + assert Exception.message(error) =~ "context: item 4" + end + end + + test "assert with error_context preserves match bindings" do + {:ok, 123} = assert({:ok, value} = {:ok, 123}, error_context: :loop_item) + 123 = value + end + + test "assert with message keeps match semantics when diff is disabled" do + assert_raise MatchError, fn -> + assert {:ok, _} = error(true), "expected {:ok, _}" + end + end + test "assert when value evaluates to falsy" do try do assert Value.falsy() @@ -196,6 +235,20 @@ defmodule ExUnit.AssertionsTest do end end + test "refute with error_context implies diff" do + try do + refute 1 > 0, error_context: %{id: 2} + flunk("This should never be tested") + rescue + error in [ExUnit.AssertionError] -> + 1 = error.left + 0 = error.right + %{id: 2} = error.error_context + "Refute with > failed" = error.message + assert Exception.message(error) =~ "context: %{id: 2}" + end + end + test "assert match when equal" do {2, 1} = assert {2, 1} = Value.tuple()