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

Add matcher support to assert_enqueued_email_with #46626

Merged

Conversation

maxim
Copy link
Contributor

@maxim maxim commented Dec 1, 2022

Motivation / Background

Currently assert_enqueued_with allows you to pass procs for matching args.

However, assert_enqueued_email_with doesn't let you do that for args and params.

I propose that we should.

For example, if you are passing a generated token to an email, you would have to mock/stub your way to verify that it looks right in job args. Or you have to reach for the lower level assert_enqueued_with helper. With this change you could simply do something like this:

assert_enqueued_email_with UserMailer, :verification, params: -> p { p[:token] =~ /\w+/ }

Fixes #46621.

Detail

Since assert_enqueued_email_with tries to be clever about figuring out whether args are params, I decided to leave that logic completely intact (to preserve current behavior), and add another conditional branch which only kicks in if either args or params are a proc. In such a case, it never acts clever about args/params, and simply passes down a lambda that fails if any job args don't match, and succeeds if they do.

One unrelated question: why are we currently overwriting the passed-in params? Is that a bug?

Checklist

Before submitting the PR make sure the following are checked:

  • This Pull Request is related to one change. Changes that are unrelated should be opened in separate PRs.
  • Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: [Fix #issue-number]
  • Tests are added or updated if you fix a bug or add a feature.
  • CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included.
  • CI is passing.

@maxim maxim force-pushed the assert_enqueued_email_with_matchers branch from 7ddc566 to 5a9d6b9 Compare December 1, 2022 17:46
@maxim maxim changed the title Add matcher support to assert_enqueued_email_with Add matcher support to assert_enqueued_email_with Dec 5, 2022
@swanson
Copy link
Contributor

swanson commented May 10, 2023

It would be nice if this could support skipping the arg/param checking if nothing is provided. I have often wanted to test that the expected Mailer method was enqueued, but I don't particularly care to check the args/params (this is especially annoying if you use named parameters for mailer methods as you have to match the string-keyed hash).

assert_enqueued_email_with ManagerMailer, :bounced_email do
  ManagerMailer.bounced_email(
    plan: plan,
    to_user: user,
    subject: message[:subject],
    error: description
  ).deliver_later
end
``

@maxim
Copy link
Contributor Author

maxim commented May 10, 2023

@swanson Wouldn't assert_enqueued_emails take care of that? Or are you worried that you aren't confirming the email type, just that it was 1 email?

I do like the idea regardless, and wouldn't mind adding support for this.

Copy link
Member

@jonathanhefner jonathanhefner left a comment

Choose a reason for hiding this comment

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

This seems like a helpful addition. 👍

Comment on lines 164 to 196
expected_args = if args.is_a?(Proc) || params.is_a?(Proc)
-> (job_args) {
# Ordered from the least to the most heavy checks.
return false if [mailer.to_s, method.to_s, "deliver_now"] != job_args[0..2]
return false if !params.is_a?(Proc) && params != job_args[3][:params]
return false if !args.is_a?(Proc) && Array(args) != job_args[3][:args]
return false if args.is_a?(Proc) && !args.(job_args[3][:args])
return false if params.is_a?(Proc) && !params.(job_args[3][:params])

true
}
else
[mailer.to_s, method.to_s, "deliver_now", args: Array(args)]
if args.is_a?(Hash)
[mailer.to_s, method.to_s, "deliver_now", params: args, args: []]
elsif params.present?
[mailer.to_s, method.to_s, "deliver_now", params: params, args: Array(args)]
else
[mailer.to_s, method.to_s, "deliver_now", args: Array(args)]
end
end
Copy link
Member

Choose a reason for hiding this comment

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

This code was a bit hard for me to follow. I've pushed a commit that unifies the logic. Let me know what you think. If you like it, you or I can rebase and squash the commits.

Copy link
Contributor Author

@maxim maxim left a comment

Choose a reason for hiding this comment

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

@jonathanhefner Thanks for the review. Your version is much smaller, which is always a win. 👍🏻 However, it raised a couple of questions.

actionmailer/lib/action_mailer/test_helper.rb Show resolved Hide resolved
actionmailer/lib/action_mailer/test_helper.rb Show resolved Hide resolved
Comment on lines 164 to 188
expected_args = if args.is_a?(Proc) || params.is_a?(Proc)
-> (job_args) {
# Ordered from the least to the most heavy checks.
return false if [mailer.to_s, method.to_s, "deliver_now"] != job_args[0..2]
return false if !params.is_a?(Proc) && params != job_args[3][:params]
return false if !args.is_a?(Proc) && Array(args) != job_args[3][:args]
return false if args.is_a?(Proc) && !args.(job_args[3][:args])
return false if params.is_a?(Proc) && !params.(job_args[3][:params])
params, args = args, nil if args.is_a?(Hash)
args = Array(args) unless args.is_a?(Proc)
Copy link
Contributor Author

@maxim maxim May 10, 2023

Choose a reason for hiding this comment

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

Wouldn't we want to support a situation when both args and params are passed in, even if args is a Hash?

The original implementation didn't seem to support it, so my PR was proposing to carefully fix it (I thought may've been a bug) alongside adding support for procs, but without breaking existing tests. The bug fix would only kick in if either args or params is a proc (something that wasn't supported before). It would switch to the "new mode" of testing. In the "new mode" if you put hash in either of them, it gets tested.

Your simplification makes it such that if you try passing args: { foo: 'bar' }, params: ->(_){ … }, then expected params are overwritten with expected args and get compared against actual params, so the original expected params (the proc) is dropped. Additionally, expected args become [], so actual args are still being compared, but against an empty array. Is that desirable? Or am I misreading this? 🤔

P.S. I think this is why I ended up writing it that way. 😬 I seem to remember my original attempt was much shorter, but didn't handle this.

Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't we want to support a situation when both expected args and params are passed in, even if args is a Hash?

The original implementation didn't seem to support it, so my PR was proposing to carefully fix it (I thought may've been a bug) alongside adding support for procs

I have not dug into why the original code was written that way, but it feels like a separate concern, so I think it would be better to address it in another PR.

The bug fix would only kick in if either args or params is a proc (something that wasn't supported before). It would switch to the "new mode" of testing.

That seems unintuitive to me. I think if we change the behavior, we should change it for all types of arguments.

Part of the reason I factored params = ... and args = ... out from the comparison logic is to make that kind of change easier, as well as implementing the suggestion from #46626 (comment). (But, again, as a separate PR with a dedicated discussion.)

Copy link
Contributor Author

@maxim maxim May 10, 2023

Choose a reason for hiding this comment

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

I have not dug into why the original code was written that way, but it feels like a separate concern, so I think it would be better to address it in another PR. […] I think if we change the behavior, we should change it for all types of arguments.

I guess depends how you think about it.

Option 1: Add new feature "correctly", then fix old feature as a breaking change.
Option 2: Add new feature "brokenly", but consistently with old feature, then fix both of them with a breaking change for both.

I honestly prefer option 1, even though it feels a bit unintuitive. It minimizes unintended behavior.

And since this doesn't seem urgent, we also have these:

Option 3: dig into old feature, fix it, then add this new feature
Option 4: fix them all at once

Since I'm not part of the Rails team, I can only provide reasoning/feedback, but ultimately respect whatever decision you make. Let me know if you want to go option 2, and I can help with the rebase/squash.

Copy link
Member

Choose a reason for hiding this comment

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

The :params kwarg was introduced in #45752. The lesser precedence of :params vs :args seems to be intentional. Code that specifies :params is expected to specify :args as an array (if at all).

I think that is a reasonable expectation, so I would not call it "broken". Though I agree it also isn't ideal. #45752 mentions possibly deprecating the old behavior. I have submitted #48194 to do so.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gotcha, yeah I was calling it "broken" for brevity, definitely a nuanced case. Nice, very efficient work. 🎉👍🏻

@swanson
Copy link
Contributor

swanson commented May 11, 2023

@swanson Wouldn't assert_enqueued_emails take care of that? Or are you worried that you aren't confirming the email type, just that it was 1 email?

I do like the idea regardless, and wouldn't mind adding support for this.

Yes, I usually just end up with assert_enqueued_emails but I would prefer to check that the actual mailer is called!

Support args/params proc matchers in `assert_enqueued_email_with` to
make it behave more like `assert_enqueued_with`.

Fix rails#46621.

Co-authored-by: Jonathan Hefner <jonathan@hefner.pro>
@jonathanhefner jonathanhefner force-pushed the assert_enqueued_email_with_matchers branch from a6e7157 to 96f19e9 Compare May 11, 2023 17:41
@jonathanhefner jonathanhefner merged commit cb1073e into rails:main May 11, 2023
9 checks passed
@jonathanhefner
Copy link
Member

I rebased and squashed, and submitted #48194 as a follow-up (see #46626 (comment)).

Thank you, @maxim! 📬

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Should assert_enqueued_email_with accept args/params matchers?
3 participants