apollo-client icon indicating copy to clipboard operation
apollo-client copied to clipboard

MockedProvider with jest `fakeTimers` causes unexpected behavior

Open blimmer opened this issue 3 years ago • 3 comments

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.

Screenshot 2022-10-12 at 17-40-03 Actions · stephenh_react-apollo-sandbox

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

Screenshot 2022-10-12 at 17-42-40 Actions · stephenh_react-apollo-sandbox

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

blimmer avatar Oct 12 '22 23:10 blimmer

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.

christo8989 avatar Feb 28 '24 18:02 christo8989

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.

phryneas avatar Feb 29 '24 15:02 phryneas

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

lennym avatar Jun 25 '24 10:06 lennym