Am I the only one who can't spot the difference in map keys

This is a proposal to make the map key mismatch errors a bit better:
Every time I have a typo It’s very challenging for me even when I understand the error:


I see this in many places - it’s the same in test results. There may be a bit easier because the colors but because the order of keys sometimes changes and the maps are often huge I need to scroll the screen multiple times.
I even changed the test formatter to show only the lines that have ansi sequences but I work on different projects and the formatter needs to be always installed. Similar with errors thrown by dialyzer or hammox.

1 Like

Something to do with the refunded_at key? One is nil, the other gets a value from somewhere.

both clauses expect a :amount key that is not present in the map that is passed as argument.

edit: not sure if your question is about the error or a better way to format this.
in the way you’re using it, it would be better to have a struct or at least use the dot notation to extract the values instead of pattern matching them on function clause:

def build(something) do
  created_at = something.created_at
  ...
end
1 Like

@cevado I mean better formatting. I updated the op mentioning this.

Pattern-matching on huge maps is, IMO, hard to read in code - and correspondingly harder to read in test failures.

For instance, the error message you posted suggests there are two build heads:

def build(%{
  created_at: created_at,
  refunded_at: nil,
  id: job_id,
  provider_id: provider_id,
  sale_id: sale_id,
  customer_id: customer_id,
  appointment_id: appointment_id,
  amount: total_gross,
  type: fee_type,
  currency_code: currency_code
}) do
...
end

def build(%{
  created_at: created_at,
  refunded_at: refunded_at,
  id: job_id,
  provider_id: provider_id,
  sale_id: sale_id,
  customer_id: customer_id,
  appointment_id: appointment_id,
  amount: total_gross,
  type: fee_type,
  currency_code: currency_code
}) do
...t=
end

Sharp-eyed readers may spot that refunded_at is matched to nil in one and bound to refunded_at in the other, but it’s the same kind of difficulty you’re highlighting with the test failure message.

+1 for @cevado’s suggestion - writing this as:

def build(args) do
  total_gross = args.amount
  ...
end

would make the test in your original post fail with a KeyError complaining explicitly about amount not being present in args.

This is just one example and it’s not only pattern matching related. In tests or dialyzer errors the comparison is between 2 maps that are very similar and you may get it randomly because something started to fail. This was always causing issues for me and I wanted to raise it. I’m happy to implement it when we have a good idea how to handle this.

this specific formatting only happens on function clause errors, i don’t think it can be better formatted.
for tests if you’re using ExUnit it shows only the diff with it highlighted if you’re asserting a pattern match. my suggestion in that case is to do something like:

expected_response = %{...}
...
{:ok, response} = function_testing(...)

assert expected_response = response

We have a big elixir codebase and sometimes the expected response is 100 lines long. I know it shouldn’t be like that but it is how it is.

At this point I’d suggest refactoring assertions to work on smaller subsets instead of comparing the whole response at once:

assert {:ok, response} = function_testing(…)

assert something == response.a
assert something_else == response.b
assert something_else_again == response.c

I need to try how a llm will handle the refactor, otherwise it may take a lifetime :smiley:

Having not used LLMs myself, if that doesn’t work you might have some luck with Sourceror depending on how consistent your map assertions are.

1 Like

Yeah, should be doable with sourceror, given the following plain elixir poc:

"""
defmodule MyApp.Test do
  test "abc" do
    y = 15
    assert %{a: ^y, b: 8} = call()
  end
end
"""
|> Code.string_to_quoted!(
  literal_encoder: &{:ok, {:__block__, &2, [&1]}},
  token_metadata: true,
  unescape: false
)
|> Macro.postwalk(fn
  {:assert, assert_meta, [{:=, assign_meta, [{:%{}, _, pairs}, call]}]} ->
    checks =
      Enum.map(pairs, fn {{:__block__, _meta, [key]}, value} ->
        quote do
          assert unquote(value) = response.unquote(key)
        end
      end)

    {:__block__, [],
     [
       {:assert, assert_meta, [{:=, assign_meta, [Macro.var(:response, nil), call]}]} | checks
     ]}

  other ->
    other
end)
|> Code.quoted_to_algebra(
  literal_encoder: &{:ok, {:__block__, &2, [&1]}},
  token_metadata: true,
  unescape: false
)
|> Inspect.Algebra.format(:infinity)
|> IO.iodata_to_binary()
|> IO.puts()
# defmodule MyApp.Test do
#   test "abc" do
#     y = 15
#     (assert response = call()
#     assert ^y = response.a
#     assert 8 = response.b)
#   end
# end

Not sure how to best get rid of the parenthesis for the block there though.

1 Like

Whoa, cool! I would have not been able to throw that together so quickly :sweat_smile: Sourceror should take care of the parens, no? Not actually sure. I guess not because we’re writing new code :thinking: Removing them manually wouldn’t be the biggest deal considering the time-savings of code-rewriting (though I shouldn’t say that as it’s not my project we’re talking about).

I love side projects but it feels like the discussion moves a bit off topic :smiley:

I only pushed it because this is how I solved this problem for myself. I was still learning at the time so I had a tiny project and didn’t need to do any programatic code-rewriting, but I started writing single assertions per key for this very reason.

I just tried latest elixir and the error is different but even more confusing:

defmodule TestMap do
  def a(%{pies: pies, kot: kot, mysz: mysz, slon: slon, zyrafa: zyrafa, zaba: zaba, ryba: ryba, kogut: kogut}), do: :a
  def a(%{pies: pies, kot: kot, mysz: mysz, slon: slon, zyrafa: zyrafa, zaba: zaba, ryba: ryba, kura: kura}), do: :a
  def b() do
  a(%{pies: :pies, kot: :kot, mysz: :mysz, slon: :slon, zyrafa: :zyrafa, zaba: :zaba, ryba: :ryba, kon: :kura})
  end
end
** (FunctionClauseError) no function clause matching in TestMap.a/1    
    
    The following arguments were given to TestMap.a/1:
    
        # 1
        %{pies: :pies, kot: :kot, mysz: :mysz, slon: :slon, zyrafa: :zyrafa, zaba: :zaba, ryba: :ryba, kon: :kura}
    
    map.ex:2: TestMap.a/1
    map.ex:8: (file)

but you don’t need to refactor everything at once, next time a test breaks, refactor that specific test. spread the word at the company, so they stop doing the old way and more people help to do the refactor… one day it all gonna be done. :smile:

3 Likes