Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix diff output when a fuzzy finder anything is inside an expected hash #599

Merged
merged 11 commits into from
Jun 30, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Fix diff output when a fuzzy finder anything is inside an expected hash
  • Loading branch information
KarlHeitmann committed May 25, 2024
commit b993756c9ab69e5c19af1ac81a231784455bcdc1
23 changes: 21 additions & 2 deletions lib/rspec/support/differ.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ def diff(actual, expected)
if any_multiline_strings?(actual, expected)
diff = diff_as_string(coerce_to_string(actual), coerce_to_string(expected))
end
elsif no_procs?(actual, expected) && no_numbers?(actual, expected)
diff = diff_as_object(actual, expected)
elsif no_procs_and_no_numbers?(actual, expected)
if Hash === expected && hash_with_anything?(expected)
diff = diff_as_object_with_anything(actual, expected)
else
diff = diff_as_object(actual, expected)
end
end
end

Expand Down Expand Up @@ -56,6 +60,13 @@ def diff_as_string(actual, expected)
end
# rubocop:enable Metrics/MethodLength

def diff_as_object_with_anything(actual, expected)
expected.select { |_, v| RSpec::Mocks::ArgumentMatchers::AnyArgMatcher === v }.each_key do |k|
expected[k] = actual[k]
end
diff_as_object(actual, expected)
end

def diff_as_object(actual, expected)
actual_as_string = object_to_string(actual)
expected_as_string = object_to_string(expected)
Expand All @@ -73,6 +84,14 @@ def initialize(opts={})

private

def hash_with_anything?(arg)
safely_flatten(arg).any? { |a| RSpec::Mocks::ArgumentMatchers::AnyArgMatcher === a }
end

def no_procs_and_no_numbers?(*args)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m also struggling to understand how this works.
We do pass expected and actual there, and they end up wrapped in an array when flatten is called on it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed... that's weird... What if I remove the star operator from #no_procs and no_numbers method?

The purpose of this no_procs_and_no_numbers? method is to reduce the number of && operators used in the #diff method, thus appeasing rubocop by removing the Metrics::PerceivedComplexity offense.

no_procs?(args) && no_numbers?(args)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args here will be an array of two elements?
How does it work to flatten an array of two hashes?

Can we check no_procs?(expected) && no_procs?(actual) && no_numbers? ….

Each if those methods runs the (potentially expensive?) safe_flatten. I’d prefer this to be done just once (or, better - never!)

Can’t we just take top-level keys of each of those arrays without flattening recursively?

This would work on 1.8.7
No tricks with has/array structures
Early exit if there is a proc/number
No extra checks in nested values that we don’t care about
Less code
No splatting/desplatting

end

def no_procs?(*args)
safely_flatten(args).none? { |a| Proc === a }
end
Expand Down
18 changes: 18 additions & 0 deletions spec/rspec/support/differ_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,24 @@ def inspect; "<BrokenObject>"; end
expect(differ.diff(false, true)).to_not be_empty
end
end

describe "fuzzy matcher anything" do
it "outputs only key value pair that triggered diff, anything_key should absorb actual value" do
actual = { :fixed => "fixed", :trigger => "trigger", :anything_key => "bcdd0399-1cfe-4de1-a481-ca6b17d41ed8" }
expected = { :fixed => "fixed", :trigger => "wrong", :anything_key => anything }
diff = differ.diff(actual, expected)
expected_diff = dedent(<<-'EOD')
|
|@@ -1,4 +1,4 @@
| :anything_key => "bcdd0399-1cfe-4de1-a481-ca6b17d41ed8",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now when I look at this, this is indeed and improvement to -anything_key +anything_key we had.
But do we need a potentially huge value here? An uuid is fine, but what about a 5kb json-like thing?

Most certainly if we’ve anythinged it in the test, we don’t care what’s inside, including the diff?

@JonRowe does this make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you concerned by the case a 5kb json-like thing would be copied into the expected -> anything value? that will definitively be a performance issue. Would it be worth doing the change? Is there a way we can make sure the 5kb json-like thing will be a shallow copy? Maybe a shallow copy will mitigate the performance issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a shallow copy will not impact memory ram consumption. But what about CPU time? does diff_as_object iterates the actual and expected vars regardless of their .object_id? or it checks the object_id and if they match it will cease any iteration?

Another question: rspec-support module should be allowed to mutate expected vars? regardless they will be later on return them to their original state? My proposed changes on this PR are a hacky way to fold noise when the developers use anything fuzzy matcher on their hash, I understand this may not be allowed to do here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently, a 5kb json-like thing will impact CPU time ONLY if the matcher fails.

  def diff_as_object(actual, expected)
    actual_as_string = object_to_string(actual) # STEP 1
    expected_as_string = object_to_string(expected) # STEP 2
    diff_as_string(actual_as_string, expected_as_string) # STEP 3
  end

STEP 1 and STEP 2 will get the string representation of 5kb json-like thing in two different strings. STEP 3 will perform the comparison of both strings. This will be an expensive operation because both strings will be huge. In contrast, without the changes on this PR, comparing the string representation of this 5kb json-like thing with anything fuzzy matcher would have been easy-peasy

AFAIK, RSpec::Support::Differ is called when an expectation matcher fails AND the expectation matcher responds to diffable?. If the matcher does not responds to diffable, expected and actual vars are assigned nil and RSpec::Support::Differ will not perform any action at all.

My assessment is this will impact performance only when the test fails and the developer is using a matcher that produces an output-diff. But this is my assessment about performance, I still don't know the answer to: should rspec-support module be allowed to mutate expected vars in the developer test bench (ie, his _spec.rb files)? or rspec-support should remain a "pure" read-only module?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @pirj's concern was about larger diffs, but as things should be identical the differ will often not print anything at all

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Identical - yes. But if it’s neighbouring with the difference, like the ‘anything_key’ here on line 567, and it is big, i would prefer to see the literal “anything” to be printed rather than 5kb of json.

| :fixed => "fixed",
|-:trigger => "wrong",
|+:trigger => "trigger",
|
EOD
expect(diff).to be_diffed_as(expected_diff)
end
end
end
end
end
Expand Down
Loading