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

3.8.x has broken tests of components that return different tags based on loading state #11285

Closed
gzm0 opened this issue Oct 11, 2023 · 18 comments · Fixed by #11287
Closed

3.8.x has broken tests of components that return different tags based on loading state #11285

gzm0 opened this issue Oct 11, 2023 · 18 comments · Fixed by #11287

Comments

@gzm0
Copy link
Contributor

gzm0 commented Oct 11, 2023

Issue Description

First of all: I realize this sounds extremely obscure (and it's very likely that apollo-client just triggered a latent bug in something else, but maybe we can figure out where / why). It occurred on the 3.7.17 -> 3.8.0 upgrade (it just took me so long to minimize it).

If the basic Dog component on https://www.apollographql.com/docs/react/development-testing/testing/ is altered to return a <div> on success like so:

 export function Dog({ name }) {
   const { loading, error, data } = useQuery(GET_DOG_QUERY, {
     variables: { name }
   });
   if (loading) return <p>Loading...</p>;
   if (error) return <p>{error.message}</p>;
   return (
-    <p>
+    <div>
       {data.dog.name} is a {data.dog.breed}
-    </p>
+    </div>
   );
 }

The test checking both the loading and success state fails like so (jest, jsdom-environment):

$ npm test

> apollo-client-error-template@1.0.0 test
> jest

 FAIL  src/dog.test.jsx
  ✕ should render dog (50 ms)

  ● should render dog

    expect(element).toBeInTheDocument()

    element could not be found in the document

      19 |     </MockedProvider>
      20 |   );
    > 21 |   expect(await screen.findByText("Loading...")).toBeInTheDocument();
         |                                                 ^
      22 |   expect(await screen.findByText("Buck is a poodle")).toBeInTheDocument();
      23 | });
      24 |

      at toBeInTheDocument (src/dog.test.jsx:21:49)
      at call (src/dog.test.jsx:2:1)
      at Generator.tryCatch (src/dog.test.jsx:2:1)
      at Generator._invoke [as next] (src/dog.test.jsx:2:1)
      at asyncGeneratorStep (src/dog.test.jsx:2:1)
      at asyncGeneratorStep (src/dog.test.jsx:2:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.291 s, estimated 2 s
Ran all test suites.

Attention if the component is modified to return a wrong string (e.g. dropping the article), the test fails as one would expect:

Error when the component returns the wrong string
npm test

> apollo-client-error-template@1.0.0 test
> jest

 FAIL  src/dog.test.jsx
  ✕ should render dog (1088 ms)

  ● should render dog

    Unable to find an element with the text: Buck is a poodle. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

    Ignored nodes: comments, script, style
    <body>
      <div>
        <div>
          Buck
           is 
          poodle
        </div>
      </div>
    </body>

      20 |   );
      21 |   expect(await screen.findByText("Loading...")).toBeInTheDocument();
    > 22 |   expect(await screen.findByText("Buck is a poodle")).toBeInTheDocument();
         |                       ^
      23 | });
      24 |

      at waitForWrapper (node_modules/@testing-library/dom/dist/wait-for.js:162:27)
      at node_modules/@testing-library/dom/dist/query-helpers.js:86:33
      at findByText (src/dog.test.jsx:22:23)
      at call (src/dog.test.jsx:2:1)
      at Generator.tryCatch (src/dog.test.jsx:2:1)
      at Generator._invoke [as next] (src/dog.test.jsx:2:1)
      at asyncGeneratorStep (src/dog.test.jsx:2:1)
      at asyncGeneratorStep (src/dog.test.jsx:2:1)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        2.392 s
Ran all test suites.

When downgrading to apollo client 3.7.17, the issue vanishes.

Link to Reproduction

https://github.com/gzm0/apollo-client-different-tag-repro

Reproduction Steps

The reproduction repositories commit history shows the steps:

  1. Clone template.
  2. Setup dog test with jest
  3. Change p to div, observe failure.
  4. Downgrade to first failing apollo-client version.
@phryneas
Copy link
Member

From that error, it seems to be rendering "Buck is poodle", but not "Buck is a poodle". That's extremely weird, but I can't see how Apollo Client could ever trigger such a React render :/

There were some small internal timing changes in 3.8.0, but only that - rendering is always done by React.

This is very weird. We'll try to investigate (thank you for the reproduction!) but I cant guarantee that we'll find anything :/

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 11, 2023

Oh, sorry. Maybe this was more confusing than helpful. The second error happens if the component is intentionally broken. It shows that the test seems to reach the second expect.

The previous error ("element could not be found in the document") is the one caused by the bug (not any less weird though I guess :-/)

Edit: I've moved the secondary error in a <details> tag so the first error visible is the problematic one.

@dylanwulf
Copy link
Contributor

Did you upgrade from react v17 to v18 recently? Our team is in the middle of upgrading to react v18 and we're experiencing a LOT of test failures, especially when testing loading states. It seems the changes that come with Concurrent React break some assumptions we made in the tests. Not sure if that's exactly what's going on here, just thought I would give my experience in the hope that it will be helpful

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 12, 2023

@dylanwulf TY for that hint. I have attempted a React 17 downgrade (which made the bug go away). But it turns out, downgrading @testing-library/react is enough to make the bug vanish. I have bisected relevant pre-release versions:

@testing-library/react@13.4.0: OK
@testing-library/react@14.0.0-alpha.2: OK
@testing-library/react@14.0.0-alpha.3: BAD

@apollo/client@3.8.0-beta.7: OK
@apollo/client@3.8.0-rc.1: OK
@apollo/client@3.8.0-rc.2: BAD

So to trigger the bug, it seems we need at least:

  • @apollo/client@3.8.0-rc.2
  • @testing-library/react@14.0.0-alpha.3

If either (!) of these get downgraded, the bug seems to vanish.

Diffs:
testing-library/react-testing-library@v14.0.0-alpha.2...v14.0.0-alpha.3
v3.8.0-rc.1...v3.8.0-rc.2

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 12, 2023

I've managed to reproduce this in the apollo-client repository itself (test below) and managed to bisect.

The triggering change seems to be: f766e83. It seems that combined with:
testing-library/react-testing-library@f78839b it causes the issue.

What I used to bisect

Cmd: npm install && npm test -- src/react/hooks/__tests__/repro.test.tsx
Path: src/react/hooks/__tests__/repro.test.tsx

import React from "react";
import gql from "graphql-tag";
import { useQuery } from "../useQuery";

import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MockedProvider } from "../../../testing";

// Make sure that both the query and the component are exported
export const GET_DOG_QUERY = gql`
  query GetDog($name: String) {
    dog(name: $name) {
      id
      name
      breed
    }
  }
`;

export function Dog({ name }: { name: string }) {
  const { loading, error, data } = useQuery(GET_DOG_QUERY, {
    variables: { name }
  });
  if (loading) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;

  // Change the <div> to <p> to make the error go away.
  return (
    <div>
      {data.dog.name} is a {data.dog.breed}
    </div>
  );
}


test.only("issue 11285", async () => {
  const dogMock = {
    request: {
      query: GET_DOG_QUERY,
      variables: { name: "Buck" }
    },
    result: {
      data: { dog: { id: 1, name: "Buck", breed: "poodle" } }
    }
  };
  render(
    <MockedProvider mocks={[dogMock]} addTypename={false}>
      <Dog name="Buck" />
    </MockedProvider>
  );
  expect(await screen.findByText("Loading...")).toBeInTheDocument();
  expect(await screen.findByText("Buck is a poodle")).toBeInTheDocument();
});

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 12, 2023

@phryneas
Copy link
Member

Ah, that makes sense!

f766e83 changes from combining useSyncExternalState for only state retrieval with useState to trigger updates to only using useSyncExternalState for both of those jobs.

That is more in line with React's recommendations on how an external state as Apollo should be handling this, and was necessary to get the useQuery hook "in line" with some of our other hooks, but it for example now triggers a known bug in React regarding it's autobatching behaviour (setState and uSES are batched separately instead of flushing together.

What you're seeing here is just that autobatching though - React 18 batches renders that appear close to each other and only renders the last of them.

So your loading state is just never committed to the DOM. Don't ask my why it batches differently between p and div, but that seems to be some weird React gotcha here.

If you add a slight delay before the result is delivered, the test behaves as expected:

  const dogMock = {
+    delay: 100,
    request: {
      query: GET_DOG_QUERY,
      variables: { name: "Buck" }
    },
    result: {
      data: { dog: { id: 1, name: "Buck", breed: "poodle" } }
    }
  };

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 12, 2023

Hah, nice! I can confirm this fixes the issue (the whole autobatching makes sense). TY @phryneas very much appreciated.

This leaves the question how to do this correctly though. I've been playing around with setting result to a Promise that never resolves (without success). I'll keep digging, but any input is appreciated.

@phryneas
Copy link
Member

I'd say that if you want to test a loading state, you have to make sure your app stays in loading state for long enough - set a delay.

Unfortunately, Testing Library only polls the DOM for changes, so there's always risk that a change might be missed or happen too fast for RTL to pick it up - so you have to make sure things are slow enough.

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 12, 2023

The easiest way might be to separate loading state testing and loaded state testing.

Maybe like so:

it("should render dog loading", async () => {
  const dogMock = {
    request: {
      query: GET_DOG_QUERY,
      variables: { name: "Buck" }
    },
    delay: Infinity // load indefinitely
  };
  render(
    <MockedProvider mocks={[dogMock]} addTypename={false}>
      <Dog name="Buck" />
    </MockedProvider>
  );
  expect(await screen.findByText("Loading...")).toBeInTheDocument();
});

it("should render dog loaded", async () => {
  const dogMock = {
    request: {
      query: GET_DOG_QUERY,
      variables: { name: "Buck" }
    },
    result: {
      data: { dog: { id: 1, name: "Buck", breed: "poodle" } }
    }
  };
  render(
    <MockedProvider mocks={[dogMock]} addTypename={false}>
      <Dog name="Buck" />
    </MockedProvider>
  );
  expect(await screen.findByText("Buck is a poodle")).toBeInTheDocument();
});

The delay: Inifinity looks quite scary, but indeed expresses what we want from the mock: Never reply to this, to ensure the component under test stabilizes on the loading state.

If you think that is a viable solution, I can offer to:

WDYT?

@phryneas
Copy link
Member

I think having a "shared" test is of interest for most users, so I'd say something like

delay: 30 // to prevent React from batching the loading state away
// delay: Infinity // if you only want to test the loading state

in the existing example would probably be best.

As it stands, I don't think our own tests need any updates - they are working after all, but we are testing a lot of things using different paradigms.

A PR for the docs there would be very welcome :)

gzm0 added a commit to gzm0/apollo-client that referenced this issue Oct 13, 2023
@gzm0
Copy link
Contributor Author

gzm0 commented Oct 13, 2023

in the existing example would probably be best.

Fair enough. So folks can chose what works best for them. PR: #11287.

As it stands, I don't think our own tests need any updates - they are working after all, but we are testing a lot of things using different paradigms.

I more meant as a kind of "documentation test" to make sure the proposed way of testing things keeps working (especially the delay: Infinity feels quite like an edge case).

But at the end of the day, you are in a much better position to judge whether or not this is appropriate :)

@phryneas
Copy link
Member

phryneas commented Oct 16, 2023

I more meant as a kind of "documentation test" to make sure the proposed way of testing things keeps working (especially the delay: Infinity feels quite like an edge case).

That is a good point - could you also add such a test to your PR?

I think src/testing/react/__tests__/MockedProvider.test.tsx would be the right file for such a test.

@gzm0
Copy link
Contributor Author

gzm0 commented Oct 16, 2023

Done :)

@nicobatalla
Copy link

nicobatalla commented Nov 6, 2023

delay: 30 // to prevent React from batching the loading state away
// delay: Infinity // if you only want to test the loading state

Looks like this is needed after the changes in 3.8.x to avoid act() warnings on tests that used to run fine on prior versions, even if you are not trying to test the loading state. Is this expected? @phryneas

For example, any test that tries to verify that refetch is called (refetch is a mock jest function passed as newData property in the mock) now prints act warnings to the console. The same exact tests used to work fine before without the need for any delay

@phryneas
Copy link
Member

phryneas commented Nov 7, 2023

Looks like this is needed after the changes in 3.8.x to avoid act() warnings on tests that used to run fine on prior versions, even if you are not trying to test the loading state. Is this expected? @phryneas

For example, any test that tries to verify that refetch is called (refetch is a mock jest function passed as newData property in the mock) now prints act warnings to the console. The same exact tests used to work fine before without the need for any delay

Honestly: hard to say.
We are at the whim of what React does there - React 17 did it differently from React 18, and React 19 will likely have different timing again (I think canaries, e.g. Next.js, might already have this, but I could be wrong).
Right now we have moved the library more into the direction of how the React team intended the API to be used, and I would personally argue that a call to a useSyncExternalStore update function should never trigger an act warning, since it usually won't be triggered by user interaction that can be wrapped in act - but it's pretty much out of our control.

In the end the only advise I can probably give you here is: do what's working for you - even if that's a bit bitter 😞

On the upside: you'll never have a network with zero latency, so maybe adding that delay actually makes your tests more realistic.
(Also, once you switch over to useSuspenseQuery, all of these sorrows should be out of the window)

@nicobatalla
Copy link

Thanks for the context @phryneas

Copy link
Contributor

github-actions bot commented Dec 8, 2023

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.
For general questions, we recommend using StackOverflow or our discord server.

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

Successfully merging a pull request may close this issue.

4 participants