From 62c6cb5f4c8931d92d4f1171f7d496a8d21118c3 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 31 Jan 2025 12:18:54 +0100 Subject: [PATCH] Implement `assert_matches` with expanded expressions support in match assertions (#5024) * An experiment in more advanced predicates in pattern matches * More friendly pin expressions in comparison report * Use the new assert in a real test * Remove synthetic failing test * Add moduledoc * Rename `exact` to `exactly` * Make standard match bindings not throw unused warnings * Add basic support for ^strict_map() operator * Improve strict map checking to support nesting and better error report * Add checks for more common data types, including serialized time formats * Refactor the macro for a bit better legibility * Reduce code nesting further * Fix documentation definition --- .formatter.exs | 3 +- .../segments_controller_test.exs | 19 +- test/support/assert_matches.ex | 406 ++++++++++++++++++ test/support/conn_case.ex | 1 + test/support/data_case.ex | 1 + 5 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 test/support/assert_matches.ex diff --git a/.formatter.exs b/.formatter.exs index 6f8425f230bc..b5b3ce612da3 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -7,5 +7,6 @@ "{config,lib,test,extra}/**/*.{heex,ex,exs}", "priv/*/seeds.exs", "storybook/**/*.exs" - ] + ], + locals_without_parens: [assert_matches: 1] ] diff --git a/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs index 71cc6550d5b9..d6269ebee005 100644 --- a/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller/segments_controller_test.exs @@ -355,16 +355,17 @@ defmodule PlausibleWeb.Api.Internal.SegmentsControllerTest do }) |> json_response(200) - assert %{ - "name" => "Some segment", - "type" => "#{unquote(type)}", - "segment_data" => %{"filters" => [["is", "visit:entry_page", ["/blog"]]]}, - "owner_id" => user.id - } == Map.drop(response, ["id", "inserted_at", "updated_at"]) + assert_matches ^strict_map(%{ + "id" => ^any(:pos_integer), + "name" => "Some segment", + "type" => ^"#{unquote(type)}", + "segment_data" => + ^strict_map(%{"filters" => [["is", "visit:entry_page", ["/blog"]]]}), + "owner_id" => ^user.id, + "inserted_at" => ^any(:iso8601_naive_datetime), + "updated_at" => ^any(:iso8601_naive_datetime) + }) = response - assert is_integer(response["id"]) - assert is_binary(response["inserted_at"]) - assert is_binary(response["updated_at"]) assert response["inserted_at"] == response["updated_at"] verify_segment_in_db(%Plausible.Segments.Segment{ diff --git a/test/support/assert_matches.ex b/test/support/assert_matches.ex new file mode 100644 index 000000000000..c2f26c31d7df --- /dev/null +++ b/test/support/assert_matches.ex @@ -0,0 +1,406 @@ +defmodule Plausible.AssertMatches do + @moduledoc """ + Pattern match assertions wrapper macro extending it with checks expressed + directly within the pattern. + + The idea here is that the pin (^) operator does not only rebind existing + binding in the scope but also allows embedding basically any other expression + to match against the given part of the pattern. Normal pattern matching is + also supported and both can be mixed. The only caveat so far is that when + normal patterns fail, only they are listed in the error even if there are + potentially failing expressions. However, once the normal pattern is fixed, + they surface. + + Currently, the following expressions can be pinned: + + * `any(:atom)` + * `any(:string)` + * `any(:binary)` + * `any(:integer)` + * `any(:pos_integer)` + * `any(:number)` + * `any(:float)` + * `any(:boolean)` + * `any(:map)` + * `any(:list)` + * `any(:tuple)` + * `any(:iso8601_date)` + * `any(:iso8601_datetime)` + * `any(:iso8601_naive_datetime)` + * all above variants of any with a one argument predicate function accepting + value and returning a boolean, like: `any(:integer, & &1 > 20)` + * a special case of `any(:string, ~r/regex pattern/)` checking that value is + a string and matches a pattern + * shorthand version of the above, `~r/regex pattern/` + * any artibrary one argument function returning a boolean, like `&is_float/1` + or `&(&1 < 40 or &1 > 300)` + * exactly(expression) where expression is compared using equality, so that can + enforce full equality inside a pattern, like: `exactly(%{foo: 2})` which will + fail if the value is something like `%{foo: 2, other: "something}` + * any other arbitrary expression which is compared the way as if it was wrapped + with `exactly()`; this allows "interpolating" values from schemas and maps without + rebinding like `user.id` (instead of having to rebind to `user_id` first) + + There's also a special pin type, `strict_map(...)` which can wrap around any map + in the pattern. It's enforcing that the pattern has enumerated all the keys + present in the respective map in the pattern matched value. All the above mentioned + pin expressions can be also used inside `strict_map(...)` and `strict_map()` pins + can be nested. + + Usage example: + + n = %{z: 2} + + assert_matches %{ + a: ^any(:integer, &(&1 > 2)), + b: ^any(:string, ~r/baz/), + d: [_ | _], + e: ^~r/invalid/, + f: ^n.z, + g: ^(&is_float/1), + h: ^exactly(%{foo: :bar}) + } = %{ + a: 1, + b: "twofer", + c: :other, + d: [1, 2, 3], + e: "another string", + f: 1, + g: 4.2, + h: %{foo: :bar, other: "stuff"} + } + """ + + @doc @moduledoc + defmacro assert_matches({:=, meta, [pattern, value]}) do + {base_strict_pattern, strict_vars} = build_base_strict_pattern(pattern) + + base_strict_pattern = clear_bindings_except(base_strict_pattern, :match_var) + + strict_patterns = + Enum.map(strict_vars, fn strict_var -> + build_strict_pattern(base_strict_pattern, strict_var) + end) + + strict_pattern_matches = + Enum.map(strict_patterns, fn {strict_pattern, _} -> + quote do + assert unquote(strict_pattern) = unquote(value) + end + end) + + strict_pattern_checks = + Enum.map(strict_patterns, fn {strict_pattern, [{strict_var, map_pattern_keys}]} -> + build_strict_pattern_check(strict_pattern, strict_var, map_pattern_keys, pattern, value) + end) + + {var_pattern, pins} = build_var_pattern(pattern) + + clean_pattern = + Enum.reduce(pins, var_pattern, fn {var, _predicate}, pattern -> + Macro.postwalk(pattern, fn + ^var -> {:_, [], __MODULE__} + other -> other + end) + end) + + var_pattern = clear_bindings_except(var_pattern, :assert_match) + + predicate_pattern = build_predicate_pattern(var_pattern, pins) + + quote do + value = unquote(value) + assert unquote(clean_pattern) = value + unquote(strict_pattern_matches) + unquote(strict_pattern_checks) + + assert unquote(var_pattern) = value + + if unquote(length(pins) > 0) do + {errors?, predicate_pattern} = unquote(predicate_pattern) + + if errors? do + raise ExUnit.AssertionError, + message: "match (=) failed", + left: predicate_pattern, + right: value, + expr: + {:assert_matches, unquote(meta), + [{:=, [], [unquote(Macro.escape(pattern)), Macro.escape(value)]}]}, + context: {:match, []} + end + end + end + end + + defp build_base_strict_pattern(pattern) do + Macro.postwalk(pattern, [], fn + {:^, _, [{:strict_map, _, _}]} = pin, acc -> + pinned_var = Macro.unique_var(:match, __MODULE__) + + pin = Macro.update_meta(pin, &Keyword.put(&1, :match_var, pinned_var)) + + {pin, [pinned_var | acc]} + + {:^, _, _}, acc -> + {{:_, [], __MODULE__}, acc} + + other, acc -> + {other, acc} + end) + end + + defp build_strict_pattern(base_strict_pattern, strict_var) do + Macro.postwalk(base_strict_pattern, [], fn + {:^, meta, [{:strict_map, _, [pinned]}]}, acc -> + if meta[:match_var] == strict_var do + {:%{}, _, map_pattern_values} = pinned + map_pattern_keys = map_pattern_values |> Enum.map(&elem(&1, 0)) |> Enum.sort() + {strict_var, [{strict_var, map_pattern_keys} | acc]} + else + { + Macro.postwalk(pinned, fn + {:^, _, _} -> + {:_, [], __MODULE__} + + other -> + other + end), + acc + } + end + + other, acc -> + {other, acc} + end) + end + + defp build_strict_pattern_check(strict_pattern, strict_var, map_pattern_keys, pattern, value) do + quote bind_quoted: [ + pattern: Macro.escape(pattern), + value: value, + strict_pattern: Macro.escape(strict_pattern), + var: strict_var, + escaped_var: Macro.escape(strict_var), + pattern_keys: map_pattern_keys + ] do + var_keys = var |> Map.keys() |> Enum.sort() + + if pattern_keys != var_keys do + missing_keys = var_keys -- pattern_keys + + map_pattern_values = + pattern_keys + |> Enum.map(&{&1, {:_, [], __MODULE__}}) + |> Enum.concat(Enum.map(missing_keys, &{&1, :_MISSING_KEY__})) + + error_pattern = + Macro.postwalk(strict_pattern, fn + ^escaped_var -> {:%{}, [], map_pattern_values} + other -> other + end) + + raise ExUnit.AssertionError, + message: "match (=) failed", + left: error_pattern, + right: value, + expr: {:assert_matches, [], [{:=, [], [pattern, Macro.escape(value)]}]}, + context: {:match, []} + end + end + end + + defp build_predicate_pattern(var_pattern, pins) do + quote bind_quoted: [ + var_pattern: Macro.escape(var_pattern), + escaped_pins: Macro.escape(pins), + pins: pins + ] do + escaped_pins + |> Enum.zip(pins) + |> Enum.reduce({false, var_pattern}, fn {{escaped_var, escaped_predicate}, {var, predicate}}, + {errors?, pattern} -> + result = + if is_function(predicate, 1) do + not predicate.(var) + else + predicate != var + end + + if result do + escaped_predicate = Plausible.AssertMatches.Internal.strip_prefix(escaped_predicate) + + {true, + Macro.postwalk(pattern, fn + ^escaped_var -> escaped_predicate + other -> other + end)} + else + {errors?, + Macro.postwalk(pattern, fn + ^escaped_var -> {:_, [], __MODULE__} + other -> other + end)} + end + end) + end + end + + defp build_var_pattern(pattern) do + Macro.postwalk(pattern, [], fn + {:^, _meta, [{pinned, _, module}]} = normal_pin, acc + when is_atom(pinned) and is_atom(module) -> + {normal_pin, acc} + + {:^, _meta, [{:strict_map, _, [pinned]}]}, acc -> + {pinned, acc} + + {:^, _meta, [pinned]}, acc -> + pinned = Plausible.AssertMatches.Internal.transform_predicate(pinned) + + pinned_var = + Macro.unique_var(:match, __MODULE__) + |> Macro.update_meta(&Keyword.put(&1, :assert_match, true)) + + {pinned_var, [{pinned_var, pinned} | acc]} + + other, acc -> + {other, acc} + end) + end + + defp clear_bindings_except(pattern, except_meta) do + pattern + |> Macro.postwalk(fn + {:^, _, [{name, meta, module}]} = pin when is_atom(name) and is_atom(module) -> + if meta[except_meta] do + pin + else + {:_, [], __MODULE__} + end + + other -> + other + end) + |> Macro.postwalk(fn + {name, meta, module} = var when is_atom(name) and is_atom(module) -> + if meta[except_meta] do + var + else + {:_, [], __MODULE__} + end + + other -> + other + end) + end + + defmodule Internal do + @moduledoc false + + def transform_predicate({:any, _, [value]}) do + quote do + Plausible.AssertMatches.Internal.any(unquote(value)) + end + end + + def transform_predicate({:exactly, _, [value]}) do + quote do + Plausible.AssertMatches.Internal.exactly(unquote(value)) + end + end + + def transform_predicate({:any, _, [value, extra_predicate]}) do + quote do + Plausible.AssertMatches.Internal.any( + unquote(value), + unquote(extra_predicate) + ) + end + end + + def transform_predicate({:sigil_r, _, _} = regex) do + quote do + Plausible.AssertMatches.Internal.regex(unquote(regex)) + end + end + + def transform_predicate(other), do: other + + def strip_prefix({{:., _, [_prefix, f]}, _, args}) when f in [:any, :regex, :exactly] do + {f, [], args} + end + + def strip_prefix(predicate) do + predicate + end + + def any(:atom), do: &is_atom/1 + def any(:string), do: &is_binary/1 + def any(:binary), do: &is_binary/1 + def any(:integer), do: &is_integer/1 + def any(:number), do: &is_number/1 + def any(:float), do: &is_float/1 + def any(:boolean), do: &is_boolean/1 + def any(:map), do: &is_map/1 + def any(:list), do: &is_list/1 + def any(:tuple), do: &is_tuple/1 + + def any(:pos_integer) do + fn value -> + is_integer(value) and value > 0 + end + end + + def any(:iso8601_date) do + fn value -> + case Date.from_iso8601(value) do + {:ok, _} -> true + _ -> false + end + end + end + + def any(:iso8601_datetime) do + fn value -> + case DateTime.from_iso8601(value) do + {:ok, _, _} -> true + _ -> false + end + end + end + + def any(:iso8601_naive_datetime) do + fn value -> + case NaiveDateTime.from_iso8601(value) do + {:ok, _} -> true + _ -> false + end + end + end + + def any(:string, %Regex{} = regex) do + fn value -> + any(:string).(value) and regex(regex).(value) + end + end + + def any(type, predicate_fn) when is_function(predicate_fn, 1) do + fn value -> + any(type).(value) and predicate_fn.(value) + end + end + + def regex(regex) do + fn value -> + String.match?(value, regex) + end + end + + def exactly(expr) do + fn value -> + value == expr + end + end + end +end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 1837715d1706..484cd70018d8 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -24,6 +24,7 @@ defmodule PlausibleWeb.ConnCase do import Phoenix.ConnTest alias PlausibleWeb.Router.Helpers, as: Routes import Plausible.Factory + import Plausible.AssertMatches # The default endpoint for testing @endpoint PlausibleWeb.Endpoint diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 6add17200782..3eaa264f709e 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -22,6 +22,7 @@ defmodule Plausible.DataCase do import Ecto.Changeset import Plausible.DataCase import Plausible.Factory + import Plausible.AssertMatches end end