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

[Bug]: jest.restoreAllMocks behavior changed/broken #13925

Closed
jmandawg opened this issue Feb 16, 2023 · 35 comments
Closed

[Bug]: jest.restoreAllMocks behavior changed/broken #13925

jmandawg opened this issue Feb 16, 2023 · 35 comments

Comments

@jmandawg
Copy link

jmandawg commented Feb 16, 2023

Version

29.4.3

Steps to reproduce

This worked prior to version 29.4.3

mockedItme = jest.fn().mockImplementation(() => {return 'hi'});

beforeEach(() => {
  
  jest.restoreAllMocks();
  jest.resetModules();
  jest.clearAllMocks();

});

describe("mockTest", () => {

  test("test1", () => {
    expect(mockedItme()).toEqual('hi');
  });

  test("test2", () => {
    expect(mockedItme()).toEqual('hi');
  });
})

Expected behavior

Previous behavior test passed on all platforms.

Actual behavior

Now restoreAllMocks actually deletes all mocks

Additional context

No response

Environment

Mac M1, Windows 10, Linux docker node14-alpine
@mrazauskas
Copy link
Contributor

mrazauskas commented Feb 16, 2023

Could you check if the below sample is passing before v29.4.3

mockedItem = jest.fn().mockImplementation(() => {return 'hi'});

beforeEach(() => {
  mockedItem.mockRestore();
});

describe("mockTest", () => {

  test("test1", () => {
    expect(mockedItem()).toEqual('hi');
  });

  test("test2", () => {
    expect(mockedItem()).toEqual('hi');
  });
})

I mean, calling .mockRestore() (like in this example) on each mock or calling jest.restoreAllMocks() (like in your example) should do the same thing.

@jmandawg jmandawg changed the title [Bug]: jest.restoreAllMocks behavior changed/broken on window/Linux works on Mac (M1) [Bug]: jest.restoreAllMocks behavior changed/broken Feb 16, 2023
@jmandawg
Copy link
Author

jmandawg commented Feb 16, 2023

Sorry i updated the bug it actually fails on Mac now, I had version 29.4.2 on the Mac and it was working. Once i updated the Mac it failed as well.

@jmandawg
Copy link
Author

Unfortunately i'm unsure how to get a complete jest version at 29.4.2, to test your example as it pull in transitive dependencies at 29.4.3

@jmandawg
Copy link
Author

jmandawg commented Feb 16, 2023

By setting the following dependencies my test will pass:

    "jest": "29.4.2",
    "jest-mock": "29.4.2",
    "jest-runtime": "29.4.2",
    "jest-runner": "29.4.2",
    "jest-worker": "29.4.2",
    "jest-each": "29.4.2",
    "jest-environment-node": "29.4.2",
    "@jest/environment": "29.4.2",
    "@jest/fake-timers": "29.4.2",
    "@jest/types": "29.4.2",
    "@types/node": "*",
    "jest-util": "29.4.2",
    "@jest/core": "29.4.2",
    "@jest/console": "29.4.2",
    "@jest/globals": "29.4.2",
    "@jest/reporters": "29.4.2",
    "@jest/schemas": "29.4.2",
    "@jest/source-map": "29.4.2",
    "@jest/test-result": "29.4.2",
    "@jest/transform": "29.4.2",
    "jest-changed-files": "29.4.2",
    "jest-config": "29.4.2",
    "jest-docblock": "29.4.2",
    "jest-haste-map": "29.4.2",
    "jest-leak-detector": "29.4.2",
    "jest-message-util": "29.4.2",
    "jest-regex-util": "29.4.2",
    "jest-resolve": "29.4.2",
    "jest-resolve-dependencies": "29.4.2",
    "jest-snapshot": "29.4.2",
    "jest-validate": "29.4.2",
    "jest-watcher": "29.4.2",
    "jest-cli": "29.4.2"

But the test @mrazauskas posted fails.

I'm not sure why the jest team uses the "^" in the version for the transitive jest dependenciest? It Makes it very hard to downgrade to a previous version.

@mrazauskas
Copy link
Contributor

Thanks for checking. What I am trying to is:

beforeEach(() => {
  mockedItem.mockRestore();
});

and

beforeEach(() => {
  jest.restoreAllMocks();
});

as well as restoreMocks: true config option should do the same thing.

This is not the case in 29.4.2, but is fixed in 29.4.3 via #13867. That’s the change you are seeing.

@jmandawg
Copy link
Author

jmandawg commented Feb 16, 2023

If this is the case, which makes sense by reading the documentation,, I think restoreAllMocks has been broken for a very long time, and a lot of our developers are using it incorrectly in their tests.

Maybe a breaking fix like this should be saved for the v30 release?

@mlarcher
Copy link

not sure exactly what is happening yet, but our test suite is broken due to a mock not being called at all, and hardcoding the listed subdependencies above fixes it. There is not reset/restore involved.

@mrazauskas
Copy link
Contributor

Just in case, check if you don’t have restoreMocks: true in your config. It is not mentioned in this issue, but your problem sounds similar to #13927.

@mrazauskas
Copy link
Contributor

Maybe a breaking fix like this should be saved for the v30 release?

I agree, but it did not look breaking: #13867. In the other hand, I think swapping jest.restoreAllMocks() with jest.clearAllMocks() or restoreMocks: true with clearMocks: true should be enough. I don’t think users really wanted to restore their mocks before each test. If they really wanted that, an issue should be raised long time ago.

@jmandawg
Copy link
Author

jmandawg commented Feb 16, 2023

Yes, we fixed our test, after a day of trying to figure out what went wrong. Please don't backport this fix to v27 or we will have a very big problem on our hands with all our old apps that just stop building....

The thing that makes this even worse is there is no way to "pin" a. specific version of jest because of the pointless use of carets "^" throughout all the nested dependencies: #3391

Which makes trying to test against 29.4.2 almost impossible when 29.4.3 is released.

@mlarcher
Copy link

    "jest": "29.4.2",
    "jest-environment-node": "29.4.2",
    "jest-mock": "29.4.2

This is enough for us to fix the issue. Still not sure it is really the same as the one raised here, though. We do have restoreMocks: true in our config, but we also have tests that fail when being the only one to run...
Could you guys check if it is enough to fix the original issue ?

@mrazauskas
Copy link
Contributor

mrazauskas commented Feb 16, 2023

With restoreMocks: true running any single test is equivalent to:

const mockedItem = jest.fn().mockImplementation(() => 'tests');

test("example", () => {
  mockedItem.mockRestore(); // <- should be called, because you ask for that in configuration (this did not work before 29.4.3, but is fixed now )

  mockedItem(); // returns `undefined` in v29.4.3
  });
})

@ghiscoding
Copy link
Contributor

ghiscoding commented Feb 16, 2023

I'm also having the same problem with v29.4.3 when Renovate (in this PR) tried to update to latest version of Jest in Lerna-Lite that I maintain (which by the way, is also used by Jest itself to publish new releases).

I only have 1 unit test failing git-push.spec.ts which is the only unit test using jest.restoreAllMocks(); and I didn't write this unit test myself, so I'm not sure how to change it to make it pass. I'm not sure exactly which mocks it restores in this particular test. For reference, here is the GitHub Action failure. Any help fixing the failure would be appreciated

EDIT

I agree, but it did not look breaking: #13867. In the other hand, I think swapping jest.restoreAllMocks() with jest.clearAllMocks()

swapping to jest.clearAllMocks(); actually seems to work... but I would go with what others have said, the new release was published as a patch version, it's totally unexpected to now all of a sudden get a failure

@jmandawg
Copy link
Author

I found from older documentation it says "restoreAllMocks()" only affects spies, but now affects all:

jest.restoreAllMocks() and restoreMocks:[boolean]
Similar to resetAllMocks(), with one very important difference. It restores the original implementation of "spies". So, it goes like "replace mocks with jest.fn(), but replace spies with their original implementation".

So, in cases where we manually assign things with jest.fn() (not spies), we have to take care of implementation restoration ourselves as jest won't be doing it.

@mrazauskas
Copy link
Contributor

mrazauskas commented Feb 16, 2023

@ghiscoding Just checked your case. All you need is jest.clearAllMocks().

The test file has many assertions around exec, like this one: expect(exec).toHaveBeenCalled(). To make this work you have to clear count of calls and this is what clearAllMocks() will do.

jest.restoreAllMocks() does clear the count as well, but it also restores this mock (since 29.4.3 fixed a bug): https://github.com/lerna-lite/lerna-lite/blob/aa35a81736930755d71b3948cdf777da14defa86/packages/version/src/__tests__/git-push.spec.ts#L16

Currently only first tests will pass and all the others will have the mock restored (with implementation removed). This is what you see running this test file.

@asapach
Copy link
Contributor

asapach commented Feb 16, 2023

Prior to v29.4.3 the ES6 Class Mocks example from the official docs worked fine with restoreMocks: true option. Now it fails with the following error:

    TypeError: this.soundPlayer.playSoundFile is not a function

       8 |   playSomethingCool() {
       9 |     const coolSoundFileName = 'song.mp3';
    > 10 |     this.soundPlayer.playSoundFile(coolSoundFileName);
         |                      ^
      11 |   }
      12 | }

Here is the repro case: https://github.com/asapach/jest-mock-issue

@mrazauskas
Copy link
Contributor

@ghiscoding Wait.. I missed mockImplementationOnce. Hm.. Those should work. Sorry. I will take a better look tomorrow. Thanks for pointing to this.

@ghiscoding
Copy link
Contributor

@ghiscoding Wait.. I missed mockImplementationOnce. Hm.. Those should work. Sorry. I will take a better look tomorrow. Thanks for pointing to this.

thanks for checking, I'll wait a bit more then before making the swap to jest.clearAllMocks() (which does make the tests pass). You can take your time to be sure, I can totally wait on this, I simply have Renovate to run once a week. I prefer to have an absolute answer before proceeding, thanks a lot :)

@mscrivo
Copy link

mscrivo commented Feb 17, 2023

I agree, but it did not look breaking: #13867. In the other hand, I think swapping jest.restoreAllMocks() with jest.clearAllMocks() or restoreMocks: true with clearMocks: true should be enough. I don’t think users really wanted to restore their mocks before each test. If they really wanted that, an issue should be raised long time ago.

doing this fixed the majority of the test failures for us. The few that remained were all legit test issues that I'm surprised worked in earlier versions.

@Vita-Meow
Copy link

Vita-Meow commented Feb 20, 2023

Because previously the restoreMocks almost did nothing, but now the restoreMocks seems equals to the reset to jest.fn() for those Non-SpyOn mocks + restore to the original implementation for those SpyOn mocks, so it cause a lot of test failed, since a lot of mock are defined on the first few lines of the file or the setupTest.js file, like some package mocks; Those have been restored;

so it just need remove restoreMocks: true if there is no special need to use it in the project

@CreativeTechGuy
Copy link

I'm a bit confused what the new practice should be. We have dozens of global mocks which are loaded via setupFilesAfterEnv which should be used in every test. But in individual tests, we may spy on or mock more things. Now that all mocks and spies are cleared before every test, it seems like it's not possible to setup global mocks anymore?

It seems like we have two options:

  • We disable restoreMocks which then will let spies bleed between test cases but allow us to use our global mocks
  • We keep restoreMocks which will then force us to mock tons of things in every test file.

If I'm understanding correctly, when restoreMocks: true, this mock example from the docs won't work anymore. That's going to be really painful as we have dozens of these sorts of things.

@mrazauskas
Copy link
Contributor

mrazauskas commented Mar 18, 2023

will let spies bleed between test cases

What does bleed exactly? The state of a mock, i.e. count of calls etc; or you are not interested to have your mocks as mocks anymore?

  • mockFn.mockRestore() turns a mock into not a mock. This also happens if you have restoreMocks: true in your configuration.
  • mockFn.mockClear() simply clears of the state, but mock is still a mock. That is why I suggested using clearMocks: true in configuration file (instead of restoreMocks: true) to clear the state automatically before each test.

@CreativeTechGuy
Copy link

@mrazauskas

What does bleed exactly? The state of a mock, i.e. count of calls etc; or you are not interested to have your mocks as mocks anymore?

The fact that it is mocked.

Let me explain it another way hopefully this will be clearer.

I see there to be two different kinds of mocks/spies. There's test-specific ones and global ones.

  • Global ones are ones like the matchMedia mock. They should be mocked all the time for every test in every file. You don't ever want these to be reset, but you do want them to be cleared so the count of calls etc doesn't carry over between test cases.
  • Test-specific ones are created within a test block or a describe block. These are specific to the surrounding test code and therefore should be completely cleared (reset and restored to the original implementation) when the block they were declared within ends.

(A simple way to think of this is at the start of every describe/test block, it creates a snapshot of what mocks exist and restores to that snapshot when that block ends. Any additional mocks that were created get reverted, any mocks that were there in the snapshot persist.)

I know we could manually restore and clear each one individually, but this is very error prone and the whole reason the restoreMocks: true and clearMocks: true configs exist.

I had thought the previous behavior (maybe this was the bugged behavior) was closer to what I described above. Not sure if it wasn't working exactly as I had expected, but it sure seemed like it. Now though after this has been "fixed", I'm not sure how to accomplish what I described in a generic way. I either need to re-mock every global mock before every test, or make sure to restore every spy/mock that was created within a test before it ends.

That is why I suggested using clearMocks: true in configuration file (instead of restoreMocks: true) to clear the state automatically before each test.

So I'm guessing there's no way to restore mocks that were created during a test before the next test? So if I spied on a method, changed the implementation to trigger some special behavior for that test case, now I need to make sure that I manually restore it before that test case ends? And somehow also restore it if the test case fails? That's a big footgun. 😞


I totally understand that this may have been long-standing bugged behavior and I'm glad it's fixed, but that might mean we need to add a new config option to accomplish what people were intending to do prior which is now very difficult to achieve.

@mrazauskas
Copy link
Contributor

Mocks are not scoped unfortunately. I guess, to make them scope aware they should be implemented somehow in jest-circus and the state should be handled there. If it would be decided to move in this direction, fake timers and timeouts probably should probably get scoped as well.

Sounds like a big job, but perhaps it is worth opening a feature request?

By the way, did you consider mockFn.withImplementation() as a sandbox solution?

@CreativeTechGuy
Copy link

What was the actual behavior of restoreMocks prior to this 29.4.3 fix? It seems like the documentation for it was incorrect so I'm trying to understand what it was actually doing in practice and how that differs from what it is doing now.

@mrazauskas
Copy link
Contributor

mrazauskas commented Mar 19, 2023

Documentation did not change. It might lack some detail here and there, but in general it is correct. Start from mockClear, mockReset and mockRestore on Mock Function page. jest.restoreAllMocks should call mockRestore on all mocks before each test. Same with restoreMocks option.

Prior behaviour is easy to check installing Jest 28. The following example will pass on Jest 28, but will fail on current version:

mockedItem = jest.fn().mockImplementation(() => {
  return "hi";
});

beforeEach(() => {
  // mockedItem.mockRestore();
  jest.restoreAllMocks();
});

describe("mockTest", () => {
  test("test1", () => {
    expect(mockedItem()).toEqual("hi");
  });

  test("test2", () => {
    expect(mockedItem()).toEqual("hi");
  });
});

jest.restoreAllMocks() should remove implementation before each test, but it does not in Jest 28. Comment jest.restoreAllMocks() line out and uncomment mockedItem.mockRestore(). Now both tests fail. Hm..

EDIT Another interesting detail. Try replacing jest.restoreAllMocks() with jest.resetAllMocks() and test will fail on Jest 28. Looks strange that restore does not do everything what reset does.

@CreativeTechGuy
Copy link

Okay so the bugged version of restoreMocks just didn't do anything it seems like? So before this fix restoreMocks: true behaved the same as restoreMocks: false?

@mrazauskas
Copy link
Contributor

Hm.. Not exactly. That is puzzling part. On surface it looks like it was restoring mocks created with jest.spyOn only. That is puzzling because documentation is mentioning something about jest.spyOn, but in very ambiguous ways.

jest.restoreAllMocks() "Equivalent to calling .mockRestore() on every mocked function", and: "Beware that jest.restoreAllMocks() only works for mocks created with jest.spyOn() [...] other mocks will require you to manually restore them."

mockFn.mockRestore()​ "Does everything that mockFn.mockReset() does", and: "mockFn.mockRestore() only works when the mock was created with jest.spyOn(). Thus you have to take care of restoration yourself when manually assigning jest.fn()."

If it is equivalent of calling .mockRestore() and does everything what mockFn.mockReset() does, why it does nothing? And what does it mean "when manually assigning jest.fn()"?

My understanding is the following:

const obj = {
  methodOne: () => true,
  methodTwo: () => false,
};

beforeEach(() => {
  jest.restoreAllMocks();
});

describe("jest.spyOn", () => {
  jest.spyOn(obj, "methodOne").mockReturnValue(false);

  test("test1", () => {
    expect(obj.methodOne()).toEqual(true);
    expect(jest.isMockFunction(obj.methodOne)).toBe(false);
  });
});

describe("jest.fn", () => {
  // manually assigned, hence cannot be restored
  obj.methodTwo = jest.fn().mockReturnValue(false);

  test("test2", () => {
    // in the following assertion Jest 28 gives `false` instead of `undefined`,
    // because `.mockRestore()` was not called on every mocked function although that was promised
    expect(obj.methodTwo()).toEqual(undefined);

    // mock is not restored, because it was manually assigned
    expect(jest.isMockFunction(obj.methodTwo)).toBe(true);
  });
});

Might look like jest.restoreAllMocks() works only for mocks created jest.spyOn(). But that is not so simple:

const obj = {
  methodOne: () => true,
  methodTwo: () => false,
};

test("test1", () => {
  const spy = jest.spyOn(obj, "methodOne").mockReturnValue(false);

  // called once
  expect(obj.methodOne()).toEqual(false);
  expect(jest.isMockFunction(obj.methodOne)).toBe(true);

  jest.restoreAllMocks();

  // called twice
  expect(obj.methodOne()).toEqual(true);
  expect(obj.methodOne()).toEqual(true);
  expect(jest.isMockFunction(obj.methodOne)).toBe(false);

  // on Jest 28 the call count is `1`. Seems like mock was not cleared,
  // this is the same count just like before calling `jest.restoreAllMocks()` 
  //
  // current version give `0` and this was what I was fixing,
  // i.e. `jest.restoreAllMocks()` was not any clearing the mocks
  // and that is why it was not touching mocks created using `jest.fn()`
  expect(spy.mock.calls).toHaveLength(1);
});

@CreativeTechGuy
Copy link

Wow! I really appreciate you trying to explain this. It begs the question, with how complex this behavior was before (which existed for a long time) and many people depended on that functionality thinking it was intentional, why was this changed in a patch with no info? This is clearly very confusing behavior. It definitely is more clear now since it's consistent, but also definitely breaking since it's notably different than what it was doing for a long time.


My takeaway from all of this is that if you were using restoreMocks: true before and it was working for you, the change to make is to keep that setting, but now ensure that any mocks which are created outside of a test case are now inside of a beforeEach block so they are remocked before every test after the previous ones are cleared. This will ensure that each test runs with a clean slate like I believe many people were hoping for.

Example:

Before:

// fetchMock.js - imported in setup.js

global.fetch = jest.fn(() => {
    return {
        /* fetch response */
    };
});

After:

// fetchMock.js - imported in setup.js

beforeEach(() => {
    global.fetch = jest.fn(() => {
        return {
            /* fetch response */
        };
    });
});

@mrazauskas
Copy link
Contributor

I agree that it was unfortunate that this change came out in a patch release. At the same time, it was hard to see that the impact will be so huge. Jest has many tests, which are using all kinds of mocks. Nothings was breaking. It looked like just another insignificant change. In the PR I was mostly adding new tests, see #13867

@CreativeTechGuy
Copy link

@mrazauskas is there any way we can help those who will be struggling with this problem after us? Maybe an amendment to the CHANGELOG which links to migration guide and calls it out as an unexpectedly breaking change. Or a pinned issue which is a summary of the problem and a few options on what someone can do going forward, etc. I checked the npm download stats and there's a significant number of users on versions prior to this change so even though it's already been out for a month, there's definitely millions of more people who will run into this issue.

@rodoabad
Copy link

If this leads to making more of our tests better, I'm all for it. We'll be making the changes in our end even if it means in the thousands (we'll find a way to automate them).

@github-actions
Copy link

This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.

@github-actions github-actions bot added the Stale label May 10, 2023
@github-actions
Copy link

github-actions bot commented Jun 9, 2023

This issue was closed because it has been stalled for 30 days with no activity. Please open a new issue if the issue is still relevant, linking to this one.

@github-actions github-actions bot closed this as not planned Won't fix, can't repro, duplicate, stale Jun 9, 2023
@github-actions
Copy link

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
Please note this issue tracker is not a help forum. We recommend using StackOverflow or our discord channel for questions.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jul 10, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

9 participants