What's the idiomatic way of doing partial pattern matching of structs?

In my tests, I use lists of input values and expected values, including error values. Say I have an error value:

  defmodule MyError do
    defexception [:location, :reason, :detail]
    # other details omitted
  end

Then I’ll have

test_cases  = [
  [{1, 2, 3}, "foo"],
  [{4, 5, 6}, "bar"],
  [{-1, -2, -3}, %MyError{location: 20, reason: :negative_input}]
]

Enum.each(test_cases, fn test_case ->
  [{n1, n2, n3}, ev] = test_case
  v = function_under_test(n1, n2, n3)
  case v do
    {:ok, v} -> assert v == ev
    {:error, e} -> match_error(e, ev)
  end
end)

The difficulty I’m having is how to formulate match_error. In practice, an error value returned from function_under_test might contain ephemeral stuff in :detail that’s different every time (e.g. pid values), but :reason and :location are always deterministic. The error values in the test_cases table will have nil for :detail, and this makes it difficult to match them. You can’t use == because the detail will be nil in ev but not so in e, and you apparently can’t use a match with a pin such as assert ^ev = e to do a partial match (can’t see why that shouldn’t work, but the match always fails). Of course one can convert e and ev to maps and brute-force it key-by-key, but is there a more elegant way? Have I missed something obvious?

The construct %MyError{location: 20, reason: :negative_input}] as a value is not the same for matching purposes - as you’ve noted, there’s the extra detail key. That’s why pinning doesn’t work.

If the set of keys-to-be-ignored is always the same, consider making match_error explicit:

def match_error(actual, expected) do
  assert {actual.location, actual.reason} == {expected.location, expected.reason}
end

If the set of keys-to-be-ignored is not always the same, consider unrolling that Enum.each and making the test more explicit.

Thanks. There are times when :detail is deterministic and times when it isn’t. so I went with a more generic solution: storing a map of expected key/values in the test case table, and then

  defp match_error(ev, s) when is_map(ev) do
    m = Map.from_struct(s)

    Enum.each(ev, fn {k, v} ->
      assert Map.has_key?(m, k)
      assert v == Map.get(m, k)
    end)
  end

You could try to define your test cases with a function:

test_cases  = [
  [{1, 2, 3}, "foo"],
  [{4, 5, 6}, "bar"],
  [{-1, -2, -3}, &Kernel.match?(%MyError{location: 20, reason: :negative_input}, &1)]
]

And then in match_error/2 assert that that function returns true when passed the error.

1 Like

Using ˋ^ˋ will make the pinned value be compared using equality (ˋ==ˋ) to the matched value. Patterns are no higher level construct on the beam and therefore cannot be composed/programmed with at runtime. You can either manually write a complete pattern or use metaprogramming at compile time.

2 Likes

Technically, this will be compared using strict equality (===/2), since floats and integers don’t match :wink:

iex(1)> a = 1 
1
iex(2)> ^a = 1.0
** (MatchError) no match of right hand side value: 1.0
1 Like