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
Current section
Files
lib/recursive_selective_match.ex
defmodule RecursiveSelectiveMatch do
@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 thei
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 (which it begins comparing based on struct type and then treats as maps).
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}`.
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 """
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?(%{__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
def matches?(expected, actual, opts) when is_map(expected) and is_map(actual) do
if opts[:standardize_keys] do
{expected, actual} = standardize_keys(expected, actual)
end
success = Enum.reduce(Map.keys(expected), true, fn key, acc ->
acc && Map.has_key?(actual, key) && matches?(Map.get(expected, key), Map.get(actual, key), opts)
end)
print_warning(expected, actual, success, opts)
end
def matches?(expected, actual, opts) when is_tuple(expected) and is_tuple(actual) do
if tuple_size(expected) >= 1 do
exp = elem(expected, 0)
act = elem(actual, 0)
matches?(exp, act, opts) &&
matches?(Tuple.delete_at(expected, 0), Tuple.delete_at(actual, 0), opts)
else
true
end
end
def matches?(expected, actual, opts) when is_list(expected) and is_list(actual) do
success = Enum.all?(expected, fn exp_key ->
Enum.any?(actual, fn(act_key) -> act_key == exp_key end)
end)
print_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_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)
print_warning(expected, actual, success, opts)
end
def matches?(:any_struct, %{__struct__: _}, opts) do
true
end
def matches?(expected, actual, opts) do
success = expected == actual
print_warning(expected, actual, success, opts)
end
defp print_warning(expected, actual, success, opts) when is_list(expected) and is_list(actual) do
unless success || opts[:suppress_warnings] do
IO.inspect("#{IO.inspect actual} does not match #{IO.inspect expected}")
end
success
end
defp print_warning(expected, actual, success, opts) do
unless success || opts[:suppress_warnings] do
IO.inspect("#{inspect actual} does not match #{inspect expected}")
end
success
end
defp standardize_keys(expected, actual) do
{expected |> AtomicMap.convert(%{safe: false}),
actual |> AtomicMap.convert(%{safe: false})}
end
end