assertions icon indicating copy to clipboard operation
assertions copied to clipboard

assert_maps_equal should have a default third argument

Open fabio-t opened this issue 2 years ago • 2 comments

I think this https://github.com/devonestes/assertions/blob/8b353fd941baee8b71181d4e8722c587a5aede87/lib/assertions.ex#L333 should have a sensible default:

if nothing is passed as keys, assume exact equivalency

  • assert_lists_equal Map.keys(left), Map.keys(right) at the beginning
  • then the deeper check key by key

if :left is passed as third argument, should convert to Maps.keys(left) and equivalently for right

Or something along those lines. What do you think? Would a PR in this sense be useful/appreciated?

fabio-t avatar Oct 05 '23 11:10 fabio-t

Not sure if this is related, but I came here because something that I struggle with a lot is wanting to be able to check if a list of results match a set of subsets because one or more keys will change from test-to-test (like an ID or a timestamp). For example, if I have a function foo which returns a list of maps:

foo(...)
> [%{id: 12, name: "John", cool?: true}, %{id: 15, name: "Sally", cool?: true}]

I could do a couple of different things:

assert length(result) == 2
Enum.any?(result, & match?(%{name: "John", cool?: false}, &1))
Enum.any?(result, & match?(%{name: "Sally", cool?: true}, &1))

Or maybe:

assert_lists_equal Enum.map(results, & &1.name) == ["John", "Sally"]
assert_lists_equal Enum.map(results, & &1.cool) == [false, true]

But it would be nice to maybe have something like:

assert_lists_match results, [%{name: "John", cool?: true}, %{name: "Sally", cool?: true}]

One question is if each element of the list should match according to match? (maybe macro magic would be needed?) or if it would be enough to assume that each element in the given list is a map or struct and that the maps given in the assertion are basically a compact way of asserting that those keys have those values. So it might be a bit like the assert_lists_equal example above, except that rather than just making sure there is the right set of values for each key you'd be checking that John is not cool and Sally is cool.

It might look something like this:

def assert_lists_match([], []), do: true
def assert_lists_match(records, asserted_maps) when length(records) != length(asserted_maps), do: false
def assert_lists_match(records, [asserted_map | rest_of_assertion]) do
  index = Enum.find_index(records, fn record ->
    Enum.all?(asserted_map, fn {asserted_key, asserted_value} ->
      record[asserted_key] == asserted_value
    end)
  end)

  if index do
    assert_lists_match(List.delete_at(records, index), rest_of_assertion)
  else
    false
  end
end

cheerfulstoic avatar Dec 17 '23 14:12 cheerfulstoic

I made a local function for myself to get on with what I was doing and I have some tests behind it now, so my draft code changed some. I needed to take care of the problem where matching records get exhausted early, so it needs to be open to looking through the whole search space.

Also, I put the assertion-part as the first argument since that seemed to be how the assertions library leans.

  def assert_lists_match([], []), do: true
  def assert_lists_match(asserted_maps, records) when length(asserted_maps) != length(records), do: false
  def assert_lists_match([subset_map | rest_of_assertion], records) do
    records
    |> Enum.with_index()
    |> Enum.any?(fn {record, index} ->
      matches_subset?(subset_map, record) && 
        assert_lists_match(rest_of_assertion, List.delete_at(records, index))
    end)
  end

  defp matches_subset?(subset_map, record) do
    Enum.all?(subset_map, fn {asserted_key, asserted_value} ->
      Map.get(record, asserted_key) == asserted_value
    end)
  end

My tests:

defmodule MyApp.TestHelpersTest do
  use ExUnit.Case, async: true

  alias MyApp.TestHelpers

  defmodule TestStruct do
    defstruct [:name, :city]
  end

  test "empty lists" do
    assert TestHelpers.assert_lists_match([], [])

    # Expected to have one element
    refute TestHelpers.assert_lists_match([%{name: "bar"}], [])

    # Expected to be empty
    refute TestHelpers.assert_lists_match([], [%{name: "bar"}])
    refute TestHelpers.assert_lists_match([], [%TestStruct{name: "bar"}])
  end

  test "single matches" do
    assert TestHelpers.assert_lists_match([%{name: "bar"}], [%{name: "bar"}])
    assert TestHelpers.assert_lists_match([%{name: "bar"}], [%TestStruct{name: "bar"}])
    assert TestHelpers.assert_lists_match([%{name: "bar"}], [%{name: "bar", city: 1}])
    assert TestHelpers.assert_lists_match([%{name: "bar"}], [%TestStruct{name: "bar", city: 1}])

    # Wrong value
    refute TestHelpers.assert_lists_match([%{name: "bar"}], [%{name: "biz", city: 1}])
    refute TestHelpers.assert_lists_match([%{name: "bar"}], [%TestStruct{name: "biz", city: 1}])
    # Wrong key
    refute TestHelpers.assert_lists_match([%{name: "bar"}], [%{biz: "bar", city: 1}])
    # Missing key
    refute TestHelpers.assert_lists_match([%{name: "bar"}], [%{city: 1}])
    refute TestHelpers.assert_lists_match([%{name: "bar"}], [%TestStruct{city: 1}])
  end

  test "multiple matches" do
    values = [
      %{name: "John"},
      %{name: "Jane"},
      %{name: "Johan"}
    ]

    # Testing that this works, but this isn't the best way to use this function
    assert TestHelpers.assert_lists_match([%{}, %{}, %{}], values)

    assert TestHelpers.assert_lists_match([%{name: "John"}, %{}, %{}], values)
    assert TestHelpers.assert_lists_match([%{}, %{name: "John"}, %{}], values)
    assert TestHelpers.assert_lists_match([%{}, %{}, %{name: "John"}], values)

    for _ <- 1..10 do
      assert TestHelpers.assert_lists_match(Enum.shuffle([%{name: "Jane"}, %{name: "Johan"}, %{name: "John"}]), values)
    end

    subsets =
      for i <- 1..200 do
        %{name: Faker.Name.name(), city: Faker.Address.city()}
      end

    for _ <- 1..10 do
      values =
        subsets
        |> Enum.map(fn record ->
          record
          |> Map.put(:id, Faker.UUID.v4())
          |> Map.put(:country, Faker.Address.En.country())
        end)

      assert TestHelpers.assert_lists_match(Enum.shuffle(subsets), values)
    end
  end
end

cheerfulstoic avatar Dec 17 '23 15:12 cheerfulstoic