Packages

Library enabling testing of deeply nested data structures while selectively ignoring irrelevant data elements / subtrees or testing just values' datatypes using :anything, :any_list, :any_map, :any_tuple, etc. or arbitary anonymous functions, like regexes or even multiple criteria

Current section

Files

Jump to
recursive_selective_match lib recursive_selective_match.ex
Raw

lib/recursive_selective_match.ex

defmodule RecursiveSelectiveMatch do
require Logger
@moduledoc """
RecursiveSelectiveMatch lets you specify a deeply nested test data structure and check
whether another actual data structure contains all keys and values specified in the
test data strucure. The actual data structure can include extra keys not mentioned in
the tes data structure. And actual data structure values will be ignored whenever the
corresponding test data structure value is :anything.
RecursiveSelectiveMatch is an Elixir library application enabling testing of
deeply nested Elixir data structures while selectively ignoring irrelevant data
elements and data structure subtrees you wish to exclude from your matching (like
primary & foreign key IDs, timestamps, and 3rd-party IDs) or testing just values'
datatypes using any of the following:
* :anything
* :any_list
* :any_map
* :any_tuple
* :any_integer
* :any_binary
* :any_atom
* :any_boolean
* :any_struct
For example, imagine you have a function that returns a nested data structure like this:
%{
players: [
%Person{id: 1187, fname: "Robert", lname: "Parrish", position: :center, jersey_num: "00"},
%Person{id: 979, fname: "Kevin", lname: "McHale", position: :forward, jersey_num: "32"},
%Person{id: 1033, fname: "Larry", lname: "Bird", position: :forward, jersey_num: "33"},
],
team: %{name: "Celtics",
nba_id: 13,
greatest_player: %Person{id: 4, fname: "Bill", lname: "Russell", position: :center, jersey_num: "6"},
plays_at: %{arena: %{name: "Boston Garden",
location: %{"city" => "Boston", "state" => "MA"}}}},
data_fetched_at: "2018-04-17 11:14:53"
}
Imagine further that each time you call this function, some details may vary. Maybe each time you
call the function, you get a random team, not always the NBA's greatest team of all time (only
team with 17 championships... #boston_strong!) and you don't care about specific ids or the data_fetched_at
time stamp or maybe even details about the players or team. But you want to test that the structure
of the data is correct and possibly confirm some of the values.
With RecursiveSelectiveMatch, you can create a generic test by specifying an expected data structure
like this:
%{
players: :any_list,
team: %{name: :any_binary,
nba_id: :any_integer,
greatest_player: :any_struct,
plays_at: %{arena: %{name: :any_binary,
location: %{"city" => :any_binary,
"state" => :any_binary}}}},
data_fetched_at: :any_binary
}
This successfully matches (you can see the test in test/recursive_selective_match_test.exs).
Alternatively, you can pass in any function as a matcher. The above can be rewritten as the
following (notice that both approaches can be used interchangeably):
%{
players: &is_list/1,
team: %{name: &is_binary/1,
nba_id: &is_integer/1,
greatest_player: :any_struct,
plays_at: %{arena: %{name: &is_binary/1,
location: %{"city" => &is_binary/1,
"state" => &is_binary/1}}}},
data_fetched_at: &is_binary/1
}
Even better, you can pass in a one-argument anonymous function and it will pass the
actual value in for testing. The following expectation will also pass with the example above:
%{
players: &(length(&1) == 3),
team: %{name: &(&1 in ["Bucks","Celtics", "76ers", "Lakers", "Rockets", "Warriors"]),
nba_id: &(&1 >= 1 && &1 <= 30),
greatest_player: %Person{id: &(&1 >= 0 && &1 <= 99),
fname: &(Regex.match?(~r/[A-Z][a-z]{2,}/,&1)),
lname: &(Regex.match?(~r/[A-Z][a-z]{2,}/,&1)),
position: &(&1 in [:center, :guard, :forward]),
jersey_num: &(Regex.match?(~r/\d{1,2}/,&1))},
plays_at: %{arena: %{name: &(String.length(&1) > 3),
location: %{"city" => &is_binary/1,
"state" => &(Regex.match?(~r/[A-Z]{2}/, &1))}}}},
data_fetched_at: &(Regex.match?(~r/2018-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, &1))
}
RecursiveSelectiveMatch currently works (at least sort of) with Elixir maps, lists,
tuples, and structs. (When comparing an expected struct against an actual struct, it begins
first compares based on struct type and then compares keys & values. When comparing an expected
map against an actual struct, by default, it only compares keys & values. To prevent expected
maps from matching actual structs, pass `strict_struct_matching: true` in your options map)
You can also pass in multiple expectations for a single value using a {:multi, ...} tuple.
The following will check that: 1) there are exactly three items in the `:players` list; and,
2) every player has an `lname` field that is a string with at least four bytes:
%{
players: {:multi, [&(length(&1) == 3),
&(Enum.all?(&1, fn(player) -> (player.lname |> byte_size()) >= 4 end))
]
}
}
After adding RecursiveSelectiveMatch to your project as a dependency, you can pass
an expected and an actual data structure to `RecursiveSelectiveMatch.matches?()` as follows.
If every element in `expected` also exists in `actual`, `matches?()` should return `true`.
If any element of `expected` is not in `actual`, `matches?()` should return `false`.
By default, when `matches?()` returns `false`, it should also display a message indicating
what data structure or element failed to match. It will not display all missing data
structures or elements but only the first it finds.
`RecursiveSelectiveMatch.matches?()` take an optional third argument, which is a map of
options:
* You can disable the default behavior of displaying the reason for the match failure by passing an options map (as a third argument) containing `%{suppress_warnings: true}`.
* You can override the default behavior of requiring that map keys be the same type and instead ignore differences between string and atom keys in maps by passing an options map (as a third argument) containing `%{standardize_keys: true}`.
* You can override the default behavior of allowing maps to match structs, and instead prevent maps from matching structs by passing an options map (as a third argument) containing `%{strict_struct_matching: true}`.
* You can override the default behavior of calling Logger.error() on errors to instead call IO.inspect() by passing an options map (as a third argument) containing `%{io_errors: true}`.
This library is a clean reimplementation and extension of SelectiveRecursiveMatch, a
library I wrote at Teladoc to solve the same problem. I have reimplemented it to
write cleaner code on my second attempt. (As Fred Brooks wrote, "plan to throw
one away; you will, anyhow.") While I wrote this library on my own time and have added
features not present in the original, my inspiration to create this and the time spent
building my initial implementation both came from Teladoc, so thank you, Teladoc!
"""
@doc """
`RecursiveSelectiveMatch.includes?(expected, actual)` tests whether `expected` exists as a member of `actual`,
where inclusion is tested using RecursiveSelectiveMatch.matches?()
"""
def includes?(expected, actual_list, _opts \\ %{})
def includes?(expected, actual_list, _opts) when is_list(actual_list) do
Enum.any?(actual_list,
fn(actual_val) ->
matches?(expected, actual_val, %{suppress_warnings: true})
end)
end
def includes?(_expected, _actual, _opts), do: false
@doc """
matches?()
## Examples
iex> RecursiveSelectiveMatch.matches?(%{what: :ever}, %{what: :ever, not: :checked})
true
iex> RecursiveSelectiveMatch.matches?(%{what: :ever, is: :checked}, %{what: :ever}, %{suppress_warnings: true})
false
"""
def matches?(expected, actual, opts \\ %{})
def matches?({:multi, list}, actual, opts) when is_list(list) do
Enum.all?(list, fn(expectation) -> matches?(expectation, actual, opts) end)
end
def matches?(%{__struct__: exp_struct} = expected, %{__struct__: act_struct} = actual, opts) do
matches?(exp_struct, act_struct, opts) &&
matches?(expected |> Map.from_struct(), actual |> Map.from_struct(), opts)
end
# Default behavior allows maps to match structs
# To prevent maps from matching structs, include `stict_struct_matching: true` in your opts map
def matches?(%{} = expected, %{__struct__: _act_struct} = actual, opts) do
case opts[:strict_struct_matching] do
true ->
false
false ->
matches?(expected, actual |> convert_struct_to_map(), opts)
nil ->
matches?(expected, actual |> convert_struct_to_map(), opts)
end
end
def matches?(expected, actual, opts) when is_map(expected) and is_map(actual) do
{expected, actual} =
cond do
opts[:standardize_keys] ->
standardize_keys(expected, actual)
true ->
{expected, actual}
end
success = Enum.reduce(Map.keys(expected), true, fn key, acc ->
has_key = Map.has_key?(actual, key)
has_correct_value = matches?(Map.get(expected, key), Map.get(actual, key), Map.merge(opts, %{suppress_warnings: true}))
if !has_key do
log_missing_map_key_warning(key, expected, actual, opts)
end
if has_key && !has_correct_value do
log_incorrect_map_value_warning(key, expected, actual, opts)
end
acc && has_key && has_correct_value
end)
log_unequal_warning(expected, actual, success, opts)
end
def matches?(expected, actual, opts) when is_tuple(expected) and is_tuple(actual) do
cond do
tuple_size(expected) > tuple_size(actual) ->
log_unequal_warning(expected, actual, false, Map.put(opts, :warning_message, "Expected tuple is larger than actual tuple"))
tuple_size(expected) < tuple_size(actual) ->
log_unequal_warning(expected, actual, false, Map.put(opts, :warning_message, "Actual tuple is larger than expected tuple"))
tuple_size(expected) >= 1 ->
is_equal = Enum.zip(expected |> Tuple.to_list(),
actual |> Tuple.to_list())
|> Enum.map(fn {exp, act} -> matches?(exp, act, opts) end)
|> Enum.all?(fn(x) -> x == true end)
if is_equal do
true
else
log_unequal_warning(expected, actual, false, opts)
false
end
true ->
true
end
end
# Look for each expected list element
# Report expected list element not in actual list
def matches?(expected, actual, opts) when is_list(expected) and is_list(actual) do
success = Enum.all?(expected, fn expected_element ->
Enum.any?(actual,
fn(actual_element) ->
matches?(expected_element,
actual_element,
Map.merge(opts, %{suppress_warnings: true}))
end)
end)
log_unequal_warning(expected, actual, success, opts)
end
def matches?(:anything, _actual, _opts) do
true
end
def matches?(:any_list, actual, _opts) when is_list(actual) do
true
end
def matches?(:any_map, actual, _opts) when is_map(actual) do
true
end
def matches?(:any_integer, actual, _opts) when is_integer(actual) do
true
end
def matches?(:any_tuple, actual, _opts) when is_tuple(actual) do
true
end
def matches?(:any_binary, actual, _opts) when is_binary(actual) do
true
end
def matches?(:any_atom, actual, _opts) when is_atom(actual) do
true
end
def matches?(:any_boolean, actual, _opts) when is_boolean(actual) do
true
end
def matches?(expected, actual, opts) when is_function(expected) do
success = expected.(actual)
log_unequal_warning(expected, actual, success, opts)
end
def matches?(:any_struct, %{__struct__: _}, _opts) do
true
end
def matches?(expected, actual, opts) do
success = expected == actual
log_unequal_warning(expected, actual, success, opts)
end
defp add_non_nil(list, val) when is_nil(val), do: list
defp add_non_nil(list, val) when is_list(list), do: [val | list]
defp log_missing_map_key_warning(key, expected, actual, opts) do
key = stringify(key)
expected = stringify(expected)
actual = stringify(actual)
error_string = "Key #{key} not present in #{inspect actual} but present in #{inspect expected}"
log_error_string(error_string, false, opts)
false
end
defp log_incorrect_map_value_warning(key, expected_map, actual_map, opts) do
printable_key = key
|> print_or_inspect()
expected_val = expected_map
|> Map.get(key)
|> print_or_inspect()
actual_val = actual_map
|> Map.get(key)
|> print_or_inspect()
string_exp_map = expected_map
|> print_or_inspect()
string_actual_map = actual_map
|> print_or_inspect()
error_string = "Key #{printable_key} is expected to have a value of #{expected_val} in #{string_exp_map} but has a value of #{actual_val} in #{string_actual_map}"
log_error_string(error_string, false, opts)
false
end
def print_or_inspect(%{__struct__: _} = val) do
inspect val
end
def print_or_inspect(%{} = val) do
inspect val
end
def print_or_inspect(val) when is_integer(val) do
val
end
def print_or_inspect(val) when is_function(val) do
inspect val
end
def print_or_inspect(val) when is_list(val) do
val
|> Enum.map(&print_or_inspect/1)
|> Enum.join(~s(, ))
|> (fn(val) -> ~s([#{val}]) end).()
end
def print_or_inspect(val) when is_tuple(val) do
inspect val
end
def print_or_inspect(val) when is_atom(val) do
inspect val
end
def print_or_inspect(val) when is_binary(val) do
inspect val
end
def print_or_inspect(val) do
val
end
defp log_unequal_warning(_expected, _actual, true, _opts), do: true
# TODO: treat maps and non-maps differently???
defp log_unequal_warning(expected, actual, success, opts) do
#expected = stringify(expected)
#actual = stringify(actual)
error_string = [Map.get(opts, :warning_message, nil), "#{print_or_inspect actual} does not match #{print_or_inspect expected}"]
|> List.foldl([], fn(val, acc) -> add_non_nil(acc, val) end)
|> Enum.reverse()
|> Enum.join(":\n")
log_error_string(error_string, success, opts)
success
end
defp log_error_string(error_string, success, opts) do
unless success || opts[:suppress_warnings] do
if opts[:io_errors] do
IO.inspect(error_string)
else
Logger.error(error_string)
end
end
end
def stringify(value) when is_binary(value) do
value
end
def stringify(value) when is_integer(value) do
inspect value
end
def stringify(value) when is_function(value) do
inspect value
end
def stringify(value) when is_tuple(value) do
inspect value
end
def stringify(value) when is_atom(value) do
inspect value
end
def stringify(value) when is_list(value) do
value
|> Enum.map(&stringify/1)
|> Enum.join(~s(, ))
|> (fn(val) -> ~s([#{val}]) end).()
end
def stringify(%_struct{} = value) do
inspect value
end
def stringify(value) when is_map(value) do
value
|> Map.keys()
|> Enum.reduce(%{},
fn(key, acc) ->
Map.put(acc, key, Map.get(value, key) |> stringify())
end)
# m |> Map.keys() |> Enum.reduce(%{}, fn(key, acc) -> Map.put(acc, key, Map.get(m, key) |> RSM.stringify()) end)
end
def stringify(value) do
inspect value
end
defp standardize_keys(expected, actual) do
{expected |> AtomicMap.convert(%{safe: false}),
actual |> AtomicMap.convert(%{safe: false})}
end
def convert_struct_to_map(%_{} = struct) do
keys_to_strip = [:__meta__, :__field__, :__queryable__, :__owner__, :__cardinality__]
map = struct
|> Map.from_struct()
Enum.reduce(keys_to_strip,
map,
fn(key_to_strip, acc) -> Map.delete(acc, key_to_strip) end)
end
end