defmodule Heyya.SnapshotTest do
@moduledoc """
`Heyya.SnapshotTest` allows for fast snapshot
testing of Phoenix components. Snapshot testing
components is a fast and easy way to ensure that
they work and produce what they expected to
produce without having to hand write
assertions.
"""
@doc """
Wire up the module to prepare for snapshot testing.
"""
defmacro __using__(_opts) do
quote do
use ExUnit.Case
import Phoenix.Component, except: [link: 1]
import Phoenix.LiveViewTest
import Heyya.SnapshotTest,
only: [component_snapshot_test: 2, component_snapshot_test: 3]
end
end
@doc """
A named component snapshot test
"""
defmacro component_snapshot_test(name, do: expr) do
quote do
test unquote(name) do
Heyya.SnapshotTest.inner_test(unquote(expr))
end
end
end
@doc """
A named component snapshot test, where context is passed through.
"""
defmacro component_snapshot_test(name, context, do: expr) do
quote do
test unquote(name), unquote(context) do
Heyya.SnapshotTest.inner_test(unquote(expr))
end
end
end
defmacro inner_test(expr) do
quote do
rendered = rendered_to_string(unquote(expr))
snapshot = Heyya.SnapshotTest.get_snapshot(unquote(Macro.escape(__CALLER__)))
case {Heyya.SnapshotTest.compare_html(snapshot, rendered), Heyya.SnapshotTest.override?()} do
{true, _} ->
# If they match (might not be string identical)
# then return ok
:ok
{false, true} ->
# If we didn't match but we're allowed to overwrite, then do that now
Heyya.SnapshotTest.override!(unquote(Macro.escape(__CALLER__)), rendered)
# If we overwrite everything then return ok
:ok
{false, false} ->
raise ExUnit.AssertionError,
left: snapshot,
right: rendered,
message: "Received value does not match stored snapshot.",
expr: "Snapshot == Received"
end
end
end
@spec compare_html(binary, binary) :: boolean
def compare_html(snapshot_value, rendered_value) do
# Parse the HTML with Floki
#
# Since we know that all the values
r = Floki.parse_fragment!(rendered_value)
s = Floki.parse_fragment!(snapshot_value)
# For every node in the fragments compare them
deep_compare(s, r)
end
@spec override? :: boolean
def override? do
System.get_env("HEYYA_OVERRIDE") == "true"
end
@spec override!(Macro.Env.t(), binary) :: :ok
def override!(%Macro.Env{} = env, content) do
base_dir = directory(env)
fname = filename(env)
File.mkdir_p!(base_dir)
base_dir |> Path.join(fname) |> File.write!(content)
# Print out some visual indication that
# we wrote a new file and skipped the test
IO.write("S")
end
defp deep_compare(expected, test_value) when is_list(expected) and is_list(test_value) do
# Deep compare is given a list of nodes. Those lists should be the same
# however OTP 26 decided that it wanted to make maps not have stable
# map order for small keys. That is what phoenix uses for @rest and attrs
#
# So we have dive into the nodes making sure they are scemantically the same.
length(expected) == length(test_value) and
expected
|> Enum.zip(test_value)
|> Enum.all?(fn {expected, test} -> deep_compare(expected, test) end)
end
# Compares two node tuples from the parsed HTML.
# The node tuples contain the node name, attributes, and content.
#
# This checks that the node names match, the attributes match
# with no regard for order, and recursively compares the content.
#
# ## Returns
#
# True if the nodes match, false otherwise.
defp deep_compare(
{expected_node, expected_attrs, expected_content},
{test_node, test_attrs, test_content}
) do
expected_node == test_node && compare_attrs(expected_attrs, test_attrs) &&
deep_compare(expected_content, test_content)
end
###
# Compares two content values when they are binaries.
#
# This trims whitespace from both binaries before comparing.
#
# ## Returns
#
# True if the trimmed content matches, false otherwise.
# """
defp deep_compare(expected_content, test_content)
when is_binary(expected_content) and is_binary(test_content) do
String.trim(expected_content) == String.trim(test_content)
end
defp deep_compare(expected_content, test_content) do
expected_content == test_content
end
defp compare_attrs(expected, test) do
sorted_expected = Enum.sort_by(expected, fn {attr_name, _value} -> attr_name end)
sorted_test = Enum.sort_by(test, fn {attr_name, _value} -> attr_name end)
sorted_expected == sorted_test
end
@doc """
Gets the directory to store snapshot files for the test.
## Parameters
- env: The macro environment which contains metadata about the test file.
## Returns
The directory path as a binary where snapshots should be stored.
"""
@spec directory(Macro.Env.t()) :: String.t()
def directory(%Macro.Env{file: file} = _env) do
path_parts = Path.split(file)
# This is kind of silly and unsafe, but I don't have a better way
#
# We are trying to get the base test directory of this project.
# However there can be directories named test in
# the path or there can be files named test
#
# So we assume the last directory that is named test is the correct one to do.
# This will produce un-expected results if there is a structure like this:
#
# mylib
# lib
# test
# test
# my_other_snap_test_file.exs
# my_snapshot_test.exs
#
# The __snapshot__ directory for `my_other_snap_test_file` will be wrong
base_dir =
path_parts
|> Enum.reverse()
|> Enum.drop_while(&(&1 != "test"))
|> Enum.reverse()
|> Path.join()
|> Path.join("__snapshots__")
# We know where the base directory for this project is, but we need
# to know the directory for this test file.
# For that we need the parts of the path that aren't in the basedir
inner_dir_parts = path_parts |> Enum.drop_while(&(&1 != "test")) |> Enum.drop(1)
# We want to group multiple tests into a directory
# named after the file the test are in.
filename_part = inner_dir_parts |> List.last() |> String.replace(".exs", "")
# Drop the filename
kept_inner_dir = Enum.drop(inner_dir_parts, -1)
suffix =
(kept_inner_dir ++ [filename_part])
# Join everything back together
|> Path.join()
Path.join(base_dir, suffix)
end
@spec filename(Macro.Env.t()) :: String.t()
def filename(%Macro.Env{function: {function_name, _}} = _env) do
base =
function_name
|> Atom.to_string()
# Collapse multiple spaces together
|> String.replace(~r/\s+/, "_")
# Function names can have slashes
|> String.replace(~r/\/+/, "__")
# Just to make sure
|> Macro.underscore()
base <> ".heyya_snap"
end
@doc """
Gets the stored snapshot for the given macro environment.
This looks up the snapshot file path and name based on the environment,
reads the file contents, and returns the snapshot.
If there is no snapshot file yet, returns an empty binary.
## Parameters
- env: The macro environment which contains metadata about the test module and function.
## Returns
The stored snapshot as a binary, or empty binary if no snapshot exists.
"""
@spec get_snapshot(Macro.Env.t()) :: binary
def get_snapshot(%Macro.Env{} = env) do
dir = directory(env)
fname = filename(env)
full_path = Path.join(dir, fname)
try do
case File.read(full_path) do
{:ok, value} -> value
{:error, _} -> ""
end
rescue
_ -> ""
end
end
end