MockedProvider with jest `fakeTimers` causes unexpected behavior
Intended outcome:
Given a simple component:
// App.tsx
import { gql, useQuery } from "@apollo/client";
export const GET_DOGS = gql`
query GetDogs {
dogs {
id
breed
}
}
`;
function App() {
const { data, loading, error } = useQuery(GET_DOGS);
if (!data && !loading && !error) {
throw new Error("Unexpected state");
}
return (
<h1>
Vite + React <div>{error?.message}</div>
</h1>
);
}
export default App;
And a jest test that uses fake timers:
// App.test.tsx
import App, { GET_DOGS } from "src/App";
import { render, screen } from "@testing-library/react";
import { MockedProvider } from "@apollo/client/testing";
describe("App", () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it("renders", async () => {
const dogMock = {
request: { query: GET_DOGS },
error: new Error("An error occurred"),
};
render(
<MockedProvider mocks={[dogMock]}>
<App />
</MockedProvider>
);
expect(await screen.findByText("An error occurred")).toBeDefined();
});
});
I expect the test to pass, as it did with versions 3.4.x and 3.6.0.
Actual outcome:
I get this error:
FAIL src/App.test.tsx (5.977 s)
App
✕ renders (5008 ms)
● App › renders
Unexpected state
14 |
15 | if (!data && !loading && !error) {
> 16 | throw new Error("Unexpected state");
| ^
17 | }
18 |
19 | return (
at App (src/App.tsx:16:11)
at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:14803:18)
at updateFunctionComponent (node_modules/react-dom/cjs/react-dom.development.js:17034:20)
at beginWork (node_modules/react-dom/cjs/react-dom.development.js:18610:16)
at HTMLUnknownElement.callCallback (node_modules/react-dom/cjs/react-dom.development.js:188:14)
at HTMLUnknownElement.callTheUserObjectsOperation (node_modules/jsdom/lib/jsdom/living/generated/EventListener.js:26:30)
at innerInvokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:350:25)
at invokeEventListeners (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:286:3)
at HTMLUnknownElementImpl._dispatch (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:233:9)
at HTMLUnknownElementImpl.dispatchEvent (node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:104:17)
at HTMLUnknownElement.dispatchEvent (node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:241:34)
at Object.invokeGuardedCallbackDev (node_modules/react-dom/cjs/react-dom.development.js:237:16)
at invokeGuardedCallback (node_modules/react-dom/cjs/react-dom.development.js:292:31)
at beginWork$1 (node_modules/react-dom/cjs/react-dom.development.js:23203:7)
at performUnitOfWork (node_modules/react-dom/cjs/react-dom.development.js:22157:12)
at workLoopSync (node_modules/react-dom/cjs/react-dom.development.js:22130:22)
at performSyncWorkOnRoot (node_modules/react-dom/cjs/react-dom.development.js:21756:9)
at node_modules/react-dom/cjs/react-dom.development.js:11089:24
at unstable_runWithPriority (node_modules/scheduler/cjs/scheduler.development.js:653:12)
at runWithPriority$1 (node_modules/react-dom/cjs/react-dom.development.js:11039:10)
at flushSyncCallbackQueueImpl (node_modules/react-dom/cjs/react-dom.development.js:11084:7)
at flushSyncCallbackQueue (node_modules/react-dom/cjs/react-dom.development.js:11072:3)
at scheduleUpdateOnFiber (node_modules/react-dom/cjs/react-dom.development.js:21199:9)
at dispatchAction (node_modules/react-dom/cjs/react-dom.development.js:15660:5)
at InternalState.state.forceUpdate (node_modules/@apollo/client/react/hooks/useQuery.js:28:9)
at InternalState.Object.<anonymous>.InternalState.setResult (node_modules/@apollo/client/react/hooks/useQuery.js:236:14)
at Object.onNext [as next] (node_modules/@apollo/client/react/hooks/useQuery.js:87:23)
at notifySubscription (node_modules/zen-observable/lib/Observable.js:135:18)
at flushSubscription (node_modules/zen-observable/lib/Observable.js:121:5)
at node_modules/zen-observable/lib/Observable.js:174:14
at node_modules/zen-observable/lib/Observable.js:73:7
I encountered this issue when upgrading a client from @apollo/client version 3.4.16 to 3.7.0.
How to reproduce the issue:
https://github.com/stephenh/react-apollo-sandbox
git clone https://github.com/stephenh/react-apollo-sandbox.git
cd react-apollo-sandbox
yarn install
yarn jest
Interestingly, this issue was introduced in the 6.5.x series, then fixed in 6.6.0, but re-introduced in 6.6.1+. See these GitHub action runs.

However, this never broke with React 18 and @testing-library/react@13. See these GitHub action runs.

Versions
System:
OS: macOS 12.6
Binaries:
Node: 18.6.0 - ~/.asdf/installs/nodejs/18.6.0/bin/node
Yarn: 1.22.19 - /opt/homebrew/bin/yarn
npm: 8.13.2 - ~/.asdf/plugins/nodejs/shims/npm
Browsers:
Chrome: 106.0.5249.103
Firefox: 106.0
Safari: 16.0
npmPackages:
@apollo/client: ^3.7.0 => 3.7.0
I'm also getting an issue on @apollo/client v3.9.5
I'm using polling on a query. So in my test, I use jest.useFakeTimers() and then await jest.advanceTimersByTimeAsync(POLL_TIME + 1). This used to work on v3.3 but now it breaks.
EDIT:
I found a workaround.
jest.useFakeTimers();
render(...)
await jest.advanceTimersByTimeAsync(1)
await waitFor(() => expect(handler).toBeCalledTimes(1))
await jest.advanceTimersByTimeAsync(POLL_TIME)
jest.useRealTimers()
await waitFor(() => expect(handler).toBeCalledTimes(2))
This works but feels kinda funky, especially having to call jest.useRealTimers() for the second call to work.
This seems like a conceptional problem with fake timers to me.
We use setTimeout to simulate the delay until a response from your network request arrives - if you use fake timers to disable those timeouts, of course that functionality cannot work :/
This is also something that a lot of our users depend on in their tests, so even if we could use "real timers" (to be clear, we can't), we couldn't switch over to a solution that would circumvent fake timers without breaking a lot of user tests.
For anyone - like me - who lands on this issue from a google search, adding the following doNotFake option to your useFakeTimers call resolved the issue for me.
jest.useFakeTimers({
doNotFake: ["setTimeout", "setInterval"]
})
Note: it seems only setTimeout was sufficient for most cases, but where I had a mock configured to throw an error it also didn't resolve until I also enabled setInterval