sinonjs / sinon

Test spies, stubs and mocks for JavaScript.
https://sinonjs.org/
Other
9.67k stars 769 forks source link

Enable use of assignment in the presence of accessors #2538

Closed fatso83 closed 1 year ago

fatso83 commented 1 year ago

Purpose

Allows specifying a fourth argument, an options bag, to sandbox.replace() to make it possible to override the default behaviour of throwing when encountering accessor methods (getters/setters).

This means we will pass the supplied replacement to the setter and vice-versa get the original value from the getter when restoring.

P.S. I am not sold on the options name, so could be something else like "allowUsingAccessor". Was just inspired by the original issue.

Background (Problem in detail) - optional

Issue #2403 made the case for using assignment, instead of defining object descriptors, in injection methods. Enabling this in all existing methods would probably break some projects in unforeseen ways. It would also mean adding yet another argument to most of these, which would make them harder to use, as some of them (spy) already have optional third arguments today.

The discussion in #2403 revealed we already had a "special case" in our Sandbox#replace method, as this already uses basic assignment. The only thing preventing it from being used was that it tries to prevent false usage by throwing on encounter accessor methods for the given property. Thus this PR simply opens up for circumenting this.

This opens up for some exotic cases, such as manual setups for stubbing the exports of true ESM modules through the innards of the module, in the way described in that feature:

my-module.mjs

export function doThing() {
    console.log("Wow, we did the thing!");
}

export function doThingTwice() {
    doThing();
    doThing();
}

export const myModule = {
    get doThing() {
        return doThing;
    },
    set doThing(value) {
        doThing = value;
    },
};

my-module.test.mjs

import { doThingTwice, myModule } from "./my-module.mjs";
import sinon from "./lib/sinon.js";

const stub = sinon.replace(myModule, "doThing", sinon.fake(), {
    forceAssignment: true,
});

doThingTwice();

sinon.assert.calledTwice(stub);

Alternatively one could, of course, not do this change, and instead say that this manual stubbing setup is already doable by exposing functions for setting the corresponding exports and manually invoking these. The downside is that this will cause lots of manual restore code, which would otherwise by handled by Sinon.

How to verify - mandatory

Run tests and alternatively try the copy-pasting the example above into the root of the repo, then run node *.test.mjs.

Checklist for author

codecov[bot] commented 1 year ago

Codecov Report

All modified lines are covered by tests :white_check_mark:

Comparison is base (f8c20e5) 95.95% compared to head (b5bd9b1) 96.03%.

:exclamation: Current head b5bd9b1 differs from pull request most recent head 3a8eaf8. Consider uploading reports for the commit 3a8eaf8 to get more accurate results

Additional details and impacted files ```diff @@ Coverage Diff @@ ## main #2538 +/- ## ========================================== + Coverage 95.95% 96.03% +0.07% ========================================== Files 40 40 Lines 1904 1916 +12 ========================================== + Hits 1827 1840 +13 + Misses 77 76 -1 ``` | [Flag](https://app.codecov.io/gh/sinonjs/sinon/pull/2538/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) | Coverage Δ | | |---|---|---| | [unit](https://app.codecov.io/gh/sinonjs/sinon/pull/2538/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) | `96.03% <100.00%> (+0.07%)` | :arrow_up: | Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs#carryforward-flags-in-the-pull-request-comment) to find out more. | [Files](https://app.codecov.io/gh/sinonjs/sinon/pull/2538?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs) | Coverage Δ | | |---|---|---| | [lib/sinon/sandbox.js](https://app.codecov.io/gh/sinonjs/sinon/pull/2538?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sinonjs#diff-bGliL3Npbm9uL3NhbmRib3guanM=) | `97.89% <100.00%> (+0.55%)` | :arrow_up: |

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

mantoni commented 1 year ago

Looks good. While the name is okay, I'm also wondering about alternatives.

Suggestion: accessorInjection or useAccessorInjection

Another question: should the whole thing fail with a specific error message if the flag is specified, but no accessor pair is found?

mantoni commented 1 year ago

One more thought: Since this is a completely different approach to swapping out a function, we could also introduce sandbox.inject(...) to make it a bit less verbose. WDYT?

fatso83 commented 1 year ago

Maybe that's just as good? We would then have define, inject and replace, all with dedicated use cases.

fatso83 commented 1 year ago

I had just about finished the changes when I found that we already have Sandbox#inject, doing something completely different (injecting a subset of the sandbox's functionality on an object). Pretty sure it is not the most well-known function and frankly I am not totally sure what its usecase outside of internal use. We even do not have any documentation on it and it is only used once (in lib/sinon/create-sandbox.js (line 53)). All of that sandbox setup related code is basically shrouded in mystery by now, so I have no idea how all of this works.

So ... I need a new name.

Currently voting for sinon.assignUsingAccessors(obj, 'doFoo', fake). Whatcha think? 🤷

#naming_is_hard

mantoni commented 1 year ago

I agree that sinon.assignUsingAccessors(obj, 'doFoo', fake) is the most clear. Quite a handful to type though, but I'm also running out of ideas.

The very last thing I can thing of: I checked the work on sinon.define(...) and it uses assignments, if I understand correctly. So the use case here would work with it, however I don't think the restorer would. Do you think it's worth trying to fold this functionality into define? Or do you prefer keeping them separate? I don't have a strong opinion here.

fatso83 commented 1 year ago

I am just thinking sinon.define has a semantic meaning: it is supposed to create something where there is nothing. So there is not much overlap in intent. I don't mind sharing parts of the implementation on the inside, but I want the naming to be clear.

What about ... namespacing it? It is kind of a special case of sinon.replace, so one could make the name itself slightly less verbose through sinon.replace.usingAccessors(...)? Might be putting lipstick on a pig, as they say over there ...

mantoni commented 1 year ago

I agree with the intent argument. It's a different thing and we should probably not mix them

I'm not really happy with the this, but can't think of anything better. Maybe @mroderick has more ideas?

mroderick commented 1 year ago

I'm not 100% sold on having a fourth argument with an obscure name that people have to remember in order to replace accessors... especially considering we already have sinon.replaceGetter and sinon.replaceSetter.

If we adopt the fourth argument, should we then deprecate replaceGetter and replaceSetter?

fatso83 commented 1 year ago

I think we already agreed to forego the fourth argument. The discussion now is basically about which dedicated name to use.

This is not about replacing accessors, Morgan, so the existing functions for replacing accessors should stay as they are. This is just about assigning values using the accessors, without replacing them. See the expanded discussion.

mroderick commented 1 year ago

@hexeberlin and I had a look at this, and now I think I'm actually caught up and understand what's going on.

How about sinon.replace.accessor(...)?

We can modify sinon.replace to mention that method, when it throws on trying to replace an accessor and we can document our way out of it, so that Future Developer will have a chance to understand what Current Author intended with using sinon.replace.accessor.

Thoughts?

fatso83 commented 1 year ago

I like it! Makes it clear that it is a special case. I can just update this and get this merged if there's agreement.

mroderick commented 1 year ago

I can just update this and get this merged if there's agreement.

Go ahead!

fatso83 commented 1 year ago

When actually writing it out, I had second thoughts about replace.accessor, since it sounds like you want to replace the accessor, so instead I resorted to replace.usingAccessor. I think it both reads better and makes more sense. Should be ready to merge, unless anyone wants more unit tests. I just copied the assertions from the normal .replace, but I haven't added additional checks for the method reusing them.