defmodule Snap do @moduledoc """ Provides macros for instant snapshot testing, that is, macros that automatically amend the source code of the test file at their call site with the evaluated value of the expression under test. """ defmodule UnexpectedLackOfException do @moduledoc false defexception message: "Expected exception but nothing was raised" end @doc """ With two arguments, `snap_assert` is equivalent to a regular `assert` """ defmacro snap_assert(arg1, arg2) do quote bind_quoted: [arg1: arg1, arg2: arg2] do assert arg1 == arg2 end end @doc """ Modifies the source code of the test file at the call site, adding the evaluated value of `arg` as the second argument. """ defmacro snap_assert(arg) do # TODO: There is a problem with duplication in the generated code; seems to be no way to use # a private macro to deduplicate (as it has to generate code using variables from surrounding context). # Another possibility is to try generating macros like `snap_assert` with another macro. # Macro call line can be obtained via `__CALLER__.line` quote do result = unquote(arg) line = __ENV__.line path = __ENV__.file :global.set_lock({path, self()}) source = File.read!(path) {_quoted, patches} = source |> Sourceror.parse_string!() |> Macro.postwalk([], fn {:snap_assert = macro_match, meta, [matched_arg]} = quoted_match, patches -> Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: false) {{:., _, [{:__aliases__, _, [:Snap]}, :snap_assert]} = macro_match, meta, [matched_arg]} = quoted_match, patches -> Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: false) quoted, patches -> {quoted, patches} end) output = Sourceror.patch_string(source, patches) File.write!(path, output, [:sync]) :global.del_lock({path, self()}) end end @doc """ With two arguments, `snap_assert_raise` is equivalent to a regular `assert_raise` """ defmacro snap_assert_raise(arg1, arg2) do quote bind_quoted: [arg1: arg1, arg2: arg2] do assert_raise arg1, arg2 end end @doc """ Modifies the source code of the test file at the call site, adding the exception produced by `fun` as the first argument (or `Snap.UnexpectedLackOfException` if no exception is produced). """ defmacro snap_assert_raise(fun) do quote do result = try do apply(unquote(fun), []) raise UnexpectedLackOfException rescue error -> error.__struct__ end line = __ENV__.line path = __ENV__.file :global.set_lock({path, self()}) source = File.read!(path) {_quoted, patches} = source |> Sourceror.parse_string!() |> Macro.postwalk([], fn {:snap_assert_raise = macro_match, meta, [matched_arg]} = quoted_match, patches -> Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: true) {{:., _, [{:__aliases__, _, [:Snap]}, :snap_assert_raise]} = macro_match, meta, [matched_arg]} = quoted_match, patches -> Snap.__patched__(quoted_match, patches, __ENV__.line, result, flip_args?: true) quoted, patches -> {quoted, patches} end) output = Sourceror.patch_string(source, patches) File.write!(path, output, [:sync]) :global.del_lock({path, self()}) end end # There appears to be no way to call from a macro another private macro which uses arguments # from the surrounding code, hence I have to rely on calling a public function (a private function # of course isn't an option either) def __patched__({macro_match, meta, [matched_arg]} = quoted, patches, line, result, flip_args?: flip_args?) do if Enum.member?(meta, {:line, line}) do range = Sourceror.get_range(quoted) replacement = {macro_match, meta, if(flip_args?, do: [result, matched_arg], else: [matched_arg, result])} |> Sourceror.to_string() patch = %{range: range, change: replacement} {quoted, [patch | patches]} else IO.puts("Line didn't match, no replacement generated") {quoted, patches} end end end