Packages

Simple and lightweight snapshot testing for Elixir.

Current section

Files

Jump to
exposure lib exposure.ex
Raw

lib/exposure.ex

defmodule Exposure do
@moduledoc """
`Exposure` adds support for snapshot testing to Elixir projects.
For more information, see [Usage](README.md#usage).
"""
@dialyzer {:no_match, maybe_add_default_path: 1}
@inspect_opts %Inspect.Opts{
printable_limit: :infinity,
limit: :infinity,
pretty: true
}
@snapshot_directory Application.compile_env(:exposure, :snapshot_directory, "_snapshots")
@snapshot_test_tag Application.compile_env(:exposure, :snapshot_test_tag, :snapshot)
@test_paths Keyword.get(Mix.Project.config(), :test_paths, [])
################################
# Public API
################################
@doc false
@spec handle_snapshot!(term(), Macro.Env.t()) :: :ok
def handle_snapshot!(value, env) do
{function_name, _} = env.function
snapshot_file = snapshot_file!(env.file, function_name)
cond do
System.get_env("EXPOSURE_OVERRIDE") == "true" ->
create_snapshot!(snapshot_file, value)
File.exists?(snapshot_file) ->
compare_snapshot!(snapshot_file, value)
true ->
raise RuntimeError, "No snapshot for test: \"#{function_name}\"."
end
end
@doc """
Defines a snapshot test.
## Examples
```elixir
describe "my_fun/2" do
test_snapshot "works as expected" do
my_fun("foo", 123)
end
end
```
"""
@spec test_snapshot(binary(), keyword()) :: Macro.t()
defmacro test_snapshot(name, opts) do
expr = Keyword.fetch!(opts, :do)
quote do
@tag unquote(@snapshot_test_tag)
test unquote(name) do
handle_snapshot!(unquote(expr), __ENV__)
end
end
end
@doc """
Defines a snapshot test that utilizes the test context.
## Examples
```elixir
describe "my_fun/3" do
test_snapshot "works as expected", ctx do
my_fun("foo", 123, ctx)
end
end
```
"""
@spec test_snapshot(binary(), Macro.t(), keyword()) :: Macro.t()
defmacro test_snapshot(name, context, opts) do
expr = Keyword.fetch!(opts, :do)
quote do
@tag unquote(@snapshot_test_tag)
test unquote(name), unquote(context) do
handle_snapshot!(unquote(expr), __ENV__)
end
end
end
################################
# Private API
################################
defp snapshot_file!(file, function) do
basename = snapshot_file_basename(function)
test_paths = test_paths()
test_paths
|> Enum.find(&String.starts_with?(file, &1))
|> case do
path when is_binary(path) ->
sub_path =
file
|> Path.rootname()
|> String.trim_leading(path)
Path.join([path, @snapshot_directory, sub_path, basename])
_ ->
raise RuntimeError, """
Snapshot tests must be located in one of the project's test paths.
"""
end
end
defp snapshot_file_basename(function) do
function
|> to_string()
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s-]/u, " ")
|> String.replace(~r/[\s-]+/, "_")
|> Kernel.<>(".snap")
end
defp test_paths do
:persistent_term.get({__MODULE__, :test_paths})
rescue
ArgumentError ->
paths =
@test_paths
|> maybe_add_default_path()
|> Enum.map(&Path.expand/1)
|> Enum.sort()
|> Enum.reduce([], &maybe_add_test_path/2)
:persistent_term.put({__MODULE__, :test_paths}, paths)
paths
end
defp maybe_add_default_path([]) do
if File.dir?("test") do
["test"]
else
[]
end
end
defp maybe_add_default_path(paths), do: paths
defp maybe_add_test_path(path, []), do: [path]
defp maybe_add_test_path(path, [last | _] = paths) do
if String.starts_with?(path, last <> "/") do
paths
else
[path | paths]
end
end
defp create_snapshot!(file, value) do
file
|> Path.dirname()
|> File.mkdir_p!()
snapshot =
value
|> Inspect.Algebra.to_doc(@inspect_opts)
|> Inspect.Algebra.group()
|> Inspect.Algebra.concat(Inspect.Algebra.line())
|> Inspect.Algebra.format(80)
File.write!(file, snapshot)
end
defp compare_snapshot!(file, value) do
{snapshot, _} = Code.eval_file(file)
if snapshot == value do
:ok
else
raise ExUnit.AssertionError,
left: value,
right: snapshot,
message: "Value does not match snapshot: #{file}",
expr: "value == snapshot"
end
end
end