TkDodo / blog-comments

6 stars 1 forks source link

blog/testing-react-query #22

Closed utterances-bot closed 2 years ago

utterances-bot commented 3 years ago

Testing React Query | TkDodo's blog

Let's take a look at how to efficiently test custom useQuery hooks and components using them.

https://tkdodo.eu/blog/testing-react-query

bonnie commented 3 years ago

This is fantastic -- thank you, Dominik!

Mmferry commented 3 years ago

Thanks a lot Dominik Can you take a look on that error please https://stackoverflow.com/questions/67354617/typeerror-reactquery-default-is-not-a-constructor

cloud-walker commented 3 years ago

didn't know about the silence errors thing, very nice!

andrewluetgers commented 3 years ago

This is exactly what I was looking for, thank you sir. Solid series all around, well done!

mauriciosoares commented 3 years ago

The Turn off retries tip should be in the official documentation. I spent hours trying to figure out why my query wasn't resolving using jest + enzyme, to only remember that the QueryClient has the retries configured to 3 by default...

TkDodo commented 3 years ago

@mauriciosoares I would appreciate a PR to the docs, maybe a small paragraph and an updated example here :)

mauriciosoares commented 3 years ago

@TkDodo I opened a PR for that https://github.com/tannerlinsley/react-query/pull/2460 🤘

I used pretty much the same info from that blog post, I don't see a reason to rephrase that, since it already pretty clear and concise

seafoam6 commented 3 years ago

Great post. A section on how to test useMutation would also be appreciated.

TkDodo commented 3 years ago

Great post. A section on how to test useMutation would also be appreciated.

Thanks, I'll see what I can do :)

janusch commented 3 years ago

@TkDodo thank you for the great article!

I am wondering how you would go about testing queryClient.getQueryData() calls. In my app I load some data on startup and then just use it from the query-cache. I would like to test components and functions that use above function.

How could I load some data into the cache?

I tried just calling the custom hook that gets the intial data in the test, but it ends up in the wrong query client. Can you think of a way on how to re-use the query client in the setup with the wrappers that you outlined above?

Would love to hear what you think about this use case for testing with the cached data.

TkDodo commented 3 years ago

I am wondering how you would go about testing queryClient.getQueryData() calls. In my app I load some data on startup and then just use it from the query-cache. I would like to test components and functions that use above function.

So I wouldn't do that, because queryClient.getQueryData() doesn't create a subscription to the queryCache. So if new data comes in, that component won't re-render on itself - only when a parent re-renders. I always advocate for: if you need data, just call useQuery, or better yet a custom hook that wraps useQuery. It will make sure your component works in isolation, not just if it's a child of a specific parent. This issue actually becomes apparent when you try to test it, because it can't "work alone". If you are worried about too many background updates, you can set a staleTime on that query. As long as the query is fresh, data will only come from the cache with useQuery. Otherwise, you can also prop-drill data down, which would make it explicit and also easier to test, because now it's just a prop.

How could I load some data into the cache?

If you really want that, you can just call queryClient.setQueryData() after you've created the queryClient to prime the cache.

janusch commented 3 years ago

@TkDodo Thank you for the feedback! That is a really good point. The thing is that the data I am loading is kind of configuration/master data that does not change much at all. And where I was first getting the data from different endpoints I ended up bundling it all into one configuration data endpoint that gives me all the different pieces. And many components just need one specific field of that config data. That is when I started creating those getCached* helpers that use getQueryData() and just return a specific field of the configuration data cache.

Using the wrappers from your examples how would I go about adding data for a specific test with queryClient.setQueryData()?

How can I get access to the same instance of the query client inside the test?

Thank you again for your guidance on this!

TkDodo commented 3 years ago

choosing the createWrapper approach, I think you can do this:

const createWrapper = (someKey, someInitialData) => {
  const queryClient = new QueryClient()
  queryClient.setQueryData(someKey, someInitialData)

  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}
janusch commented 3 years ago

Makes sense, thanks a lot! I will try out either solution and decide if it makes more sense to have the useQuery in each getCached helper or if I will setQueryData for the tests.

Looking forward to read the rest of your react-query blog series.

eoghanmccarthy commented 3 years ago

Hi, thanks for answering earlier questions on twitter. I just had one more. If I need to include other providers in renderWithClient do I declare them also in the return statement?

Like this:

export function renderWithClient(ui) {
  const testQueryClient = createTestQueryClient();
  const { rerender, ...result } = render(
    <QueryClientProvider client={testQueryClient}>
      <Provider store={mockedStore} context={ReactReduxContext}>
        <Router history={mockMemoryHistory}>
          <AuthProvider>{ui}</AuthProvider>
        </Router>
      </Provider>
    </QueryClientProvider>
  );
  return {
    ...result,
    rerender: rerenderUi =>
      rerender(
        <QueryClientProvider client={testQueryClient}>
          <Provider store={mockedStore} context={ReactReduxContext}>
            <Router history={mockMemoryHistory}>
              <AuthProvider>{rerenderUi}</AuthProvider>
            </Router>
          </Provider>
        </QueryClientProvider>
    )
  };
}

Or not, so like this?

export function renderWithClient(ui) {
  const testQueryClient = createTestQueryClient();
  const { rerender, ...result } = render(
    <QueryClientProvider client={testQueryClient}>
      <Provider store={mockedStore} context={ReactReduxContext}>
        <Router history={mockMemoryHistory}>
          <AuthProvider>{ui}</AuthProvider>
        </Router>
      </Provider>
    </QueryClientProvider>
  );
  return {
    ...result,
    rerender: rerenderUi =>
      rerender(<QueryClientProvider client={testQueryClient}>{rerenderUi}</QueryClientProvider>)
  };
}
TkDodo commented 3 years ago

@eoghanmccarthy I think you'd need to pass them all in again, so like in your first way. But that seems more like a question on how rerender from testing-library works.

Liam-Tait commented 3 years ago

Thank you, this was really useful

drmeloy commented 3 years ago

I've tried following this but it does not seem like my MSW is even running. Is there a way to confirm that the MSW is running properly? I'm not getting any errors or anything, and when I console.log my result my result.data is undefined. As far as I can tell I've followed your implementation to a T

TkDodo commented 3 years ago

@drmeloy have you seen the example repo? It has working tests, so you can clone it and work from there if you want :) You can also reach out to msw on twitter: https://twitter.com/apimocking

drmeloy commented 3 years ago

Thanks, I have seen the example repo. As far as I can tell I'm doing everything the same, but even this isn't returning me anything:

import { setupServer } from 'msw/node';
import { rest } from 'msw';

const handlers = [
  rest.get('*', (req, res, ctx) =>
    res(
      ctx.status(200),
      ctx.json({ working: 'yes please'})
    )
  )
]

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('a passing test', () => {
  it('passes', () => {
    const res = await fetch('/1099');
    if (res.ok) console.log(res.json())
  });
});

Output of console.log is Promise { }. I would expect to see { working: 'yes please' }

drmeloy commented 3 years ago

Oh and my setupTests.ts doesn't hold anything, just

import '@testing-library/jest-dom';

When I commented that out just for a sanity check nothing changed

TkDodo commented 3 years ago

Not sure if that’s just a typo, but you can only await in async functions. I can take a look at a codesandbox reproduce if you create one

eoghanmccarthy commented 3 years ago

Wondering has anyone got a useMutation status 500 test working? useMutation status 200 tests are good but the tests for 500 status are causing the test to fail.

eoghanmccarthy commented 3 years ago

On the question above here is the hook and test:

export const useCreateSomething = () => {
  return useMutation(async () => {
    const { data } = await axios.post(`/something`);
    return data;
  });
};
describe('query hooks', () => {
  test('test 500 status', async () => {
    server.use(
      rest.post(`/something`, (req, res, ctx) => {
        return res(ctx.status(500));
      })
    );

    const { result, waitFor } = renderHook(() => useCreateSomething(), {
      wrapper: createWrapper()
    });

    const { mutateAsync } = result.current;

    act(() => {
      mutateAsync();
    });

    await waitFor(() => result.current.isError);
    expect(result.current.error).toBeDefined();
  });
});

Looks like the expected values are resolved but the test still fails. Same pattern works good for testing errors with useQuery but useMutation has this issue.

eoghanmccarthy commented 3 years ago

Seems like you have to catch the error to make the test pass. This works:

const mockOnError = jest.fn();

act(() => {
      mutateAsync().catch(err => {
        mockOnError(err);
      });
});

await waitFor(() => result.current.isError);
expect(result.current.error).toBeDefined();

Issue is only with useMutation not useQuery

TkDodo commented 3 years ago

@eoghanmccarthy if you use mutateAsync, you always have to catch errors manually. This is also in the docs. I prefer to just call mutate and work with the provided onSuccess and onError callbacks.

Justinohallo commented 3 years ago

Hi @TkDodo - do you have insights into the value of these tests ?

Does it make sense to write a test for every single one of your hooks? Or should you be testing the component that calls the hook ?

My company maintains a repo specifically for sharing hooks between multiple projects. How valuable is it to test every single hook in that repo vs simply testing the components across the repos that use that hook ?

cloud-walker commented 3 years ago

Thanks, I have seen the example repo. As far as I can tell I'm doing everything the same, but even this isn't returning me anything:

import { setupServer } from 'msw/node';
import { rest } from 'msw';

const handlers = [
  rest.get('*', (req, res, ctx) =>
    res(
      ctx.status(200),
      ctx.json({ working: 'yes please'})
    )
  )
]

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('a passing test', () => {
  it('passes', () => {
    const res = await fetch('/1099');
    if (res.ok) console.log(res.json())
  });
});

Output of console.log is Promise { }. I would expect to see { working: 'yes please' }

hi @drmeloy, res.json() returns a Promise! You need to await it ☺️

TkDodo commented 3 years ago

@Justinohallo the granularity of what you want to test is, of course, up to you. I do like integration tests most, testing complete pages with cypress.

if you have a library that exposes hooks, I think it would be good if that library would make sure that its hooks are working as intended. For example, react-query also has tests internally for hooks.

Meenakshi-SR commented 2 years ago

Wondering has anyone got on how to test useMutation's onError and onSuccess ?

const useSubmitzform = ({formdata}) =>{
    const {mutate}  = useMutation((payload)=>{
       //axios call
    },
    { 
        onError: (error)=>{
            console.log(error);
        },
        onSuccess: (data)=>{
            console.log(data);
        },
}
TkDodo commented 2 years ago

@Meenakshi-SR I think it depends on what you want to test by that. Just testing if the functions have been invoked doesn't sound too appealing, because that would be testing react-query internals. We do test this, e.g. here

If you show for example a custom toast notification, it might make sense to render the hook in a component and assert that the notification is really visible on the screen. If it's just logging, mocking the log function with jest and asserting that it has been called could work.

OriAmir commented 2 years ago

Really thanks for the really great articles! few questions:

  1. How we could test use mutation hooks ? (like we test the use query hooks)
  2. Do you think it's correct to set all application handlers in the same file (handlers.ts) and then initialize every test with the server using the setupTest.ts file with all that handlers? because other component we tests not using sometimes the server at all.

another note: the waitFor dosent work for me as youe example and I have to set: await waitFor(() => expect(result.current.isSuccess).toBeTruthy());

Thanks again!

TkDodo commented 2 years ago

How we could test use mutation hooks ? (like we test the use query hooks)

I think I'll have to add that to the list of things to write about. I haven't thought much about it, but I would likely do it similar to how it's done with queries - except that you'd need to interact with a button or so first to trigger the mutation. Are you struggling with something specifically?

Do you think it's correct to set all application handlers in the same file (handlers.ts) and then initialize every test with the server using the setupTest.ts file with all that handlers? because other component we tests not using sometimes the server at all.

Actually, no. I would rather set this up on a per-test level because each test might need different response data like I'm doing it here. It doesn't hurt to set some global handlers if they fit most of your cases though.

another note: the waitFor dosent work for me as youe example

interesting - it works fine in the repo I linked ...

bsides commented 2 years ago

Hi Tk, thanks for the great post. I have a question here about react-query mutation, testing and react-router history push after a mutation.

The question is: how to test a location change with state after a react-query mutation? Short example:

function SomeComponent() {
  const history = useHistory()
  const location = useLocation()
  const mutation = useMutation(somefetch, {
    onSuccess: (data) => history.push(`/someurl/${data.id}`, { THIS_DATA_HERE: 'SOMETHING' })
  })

  return (
    <>
      {location?.state ? <p>id: {location.state.THIS_DATA_HERE}</p> : null}
      <button type="button" onClick={() => mutation.mutate({something: 'somedata'})}>create</button>
    </>
  )
}

If I test the route itself it goes as expected, the user just go there and the data is refreshed via tests. But if I send anything via history state, it doesn't get updated in the test for whatever reason. I don't know if it's a problem with jest, testing-library, react-router/history or react-query.

I also have made a codesandbox with a minimum test, showing it doesn't ever update the state from location (but in the browser it works as expected): https://codesandbox.io/s/cool-goldstine-4fizo?file=/src/App.test.js

Thank you!

TkDodo commented 2 years ago

interesting. according to this article, what you did looks correct. I don't really see why this isn't working, but I also don't think that it's a react-query issue. You can try to remove more things like react-query to narrow it down.

bsides commented 2 years ago

interesting. according to this article, what you did looks correct. I don't really see why this isn't working, but I also don't think that it's a react-query issue. You can try to remove more things like react-query to narrow it down.

Thanks for the reply @TkDodo

I also made a repo with v6 of react-router, there are some changes in the API and I hoped that would be some bug there, but at this point I pretty much doubt it:

https://codesandbox.io/s/thirsty-wind-duv4m?file=/src/App.tsx

I will do another one without react-query like you suggested to check one by one.

bsides commented 2 years ago

I went in a long research for this but it looks like something about react-query ins't right. If I remove it completely and even simulate a promise in the button click and redirect after, the test will pass.

Made another version here: https://codesandbox.io/s/qdoie?file=/src/App.tsx

72gm commented 2 years ago

interesting article, though your tests don't actually compile... "Binding element 'children' implicitly has an 'any' type.ts(7031)"

TkDodo commented 2 years ago

@72gm it compiles fine for me 🤔

nazmeln commented 2 years ago

How to test properly useInfiniteQuery hook, especially when we have getNextPageParam?

      getNextPageParam: (lastPage, allPages) => {
        if (lastPage.length < DEFAULT_PAGE_LIMIT) {
          return undefined;
        }

        return {
          offset: allPages.flat().length
        };
      },
TkDodo commented 2 years ago

@bsides I've re-added react-query mutations to your sandbox and it seems to work fine: https://codesandbox.io/s/friendly-brahmagupta-1dz7v?file=/src/App.test.js

maybe you can add more code until it breaks again? maybe you removed something else, too? Maybe it's because of the actual network request or so, because I've just used your promise stub for now ...

TkDodo commented 2 years ago

@nazmeln if you mock the request on network layer with nock or msw, you can just return the same thing your real backend would return and the query should run as if it were in production, including getNextPageParam etc.

bsides commented 2 years ago

@bsides I've re-added react-query mutations to your sandbox and it seems to work fine: https://codesandbox.io/s/friendly-brahmagupta-1dz7v?file=/src/App.test.js

maybe you can add more code until it breaks again? maybe you removed something else, too? Maybe it's because of the actual network request or so, because I've just used your promise stub for now ...

Nice, thanks for doing that. I'll try to remove everything and make a cleaner project, looks like react-query isn't the thing to be checked here, I'm more suspicious about react-router. It was just weird that removing react-query in the first example made it work. Oh well.

geekashu commented 2 years ago

I have a Login component, trimmed version is like this - The password input element is only shown after the useUserInfo has run returned basic as the hook output.

const Login = (props) => {
  const [showPasswordField, setShowPasswordField] = useState(false);
  const { userInfoQuery } = useUserInfo({
    username: loginParams.username,
    onSuccess: (res) => {
      if (res.authSource !== 'basic') {
       // Redirect to a new page
      } else {
        showPasswordField(true);
      }
    },
    onError: (err) => {
      showPasswordField(false);
    }
  });

  // Conditional Login form

useUserInfo.hook.js

  export const useUserInfo = (config) => {
  const { userInfo } = useUserSession();
  const { username, onSuccess, onError } = config;
  const { refetch: userInfoQuery } = useQuery(
    ["userInfoQuery", username],
    () => userInfo(username),
    {
      enabled: false,
      onSuccess,
      onError
    });

  return { userInfoQuery };
};

Now, I am trying to test the conditional render of the password input field, but it doesn't seems to be rendering. onSuccess call is not working in the test.

Login.test.js

  it("test conditional render if authSource is basic", async () => {

    const userInfoQuery = jest.fn();
    useUserInfo.mockImplementation((config) => ({
      userInfoQuery,
      data: {
          authSource: "basic"
      }
    }));

    const wrapper = renderWithTheme();

    await act(async () => {
      await inputFieldUsername.simulate("change", { target: { value: "test@test.com" } });
      await submitButton.simulate("submit");
    });
    expect(userInfoQuery).toHaveBeenCalledTimes(1);

    wrapper.update();

    await act(async () => {
      // After user has clicked on initial submit button(and has hit the useUserInfo hook), password field should become visible.
      const inputFieldPasswordT2 = await wrapper.find("input#password");
      console.log(inputFieldPasswordT2.debug());
      expect(inputFieldPasswordT2.exists()).toBe(true);
    });

I think, something is not right with my mockImplementation. I would appreciate it if anyone could point me in the right direction.

TkDodo commented 2 years ago

1) I think login should always be a mutation: https://twitter.com/TkDodo/status/1483157923572457476 2) you don't need state to compute the visibility of your field:

const query = useQuery()
const showPasswordField = query.isSuccess && query.data.authSource === 'basic'

not only is deriving that state much simpler and easier to grasp, it's also "safer" because onSuccess is tied to the queryFn being run, and if you use staleTime to retrieve cached data, you might get out of sync (not specific to your case, just a general remark).

geekashu commented 2 years ago

The final submission is going to be a Mutation only. It is just some initial checks to know who is the user and based on that what mutation needs to be called.

geekashu commented 2 years ago

Is there no way we can mock the onSuccess ? There are some more business logic that happens in onSuccess call.

TkDodo commented 2 years ago

if you create a complete mock for your custom hook (userInfoQuery), the react-query useQuery hook is never executed, which is likely what you want (otherwise you wouldn't have done the mock), so onSuccess is also never called. You would have to mock useQuery instead, but as the article states, I think its best to mock the network layer and execute all the code just like you would in production without mocking anything in code.

geekashu commented 2 years ago

Thanks a lot man. It helped a lot. I made some changes to the custom hook and now using isSuccess and data flag to perform the business logic. This way it has become a lot easier to mock the hook and test the component.

72gm commented 2 years ago

@72gm it compiles fine for me 🤔

Yeah your repo compiles fine. I'd just tried stealing a couple of your code snippets, which didn't!

Complicated stuff getting tests to work that use React-Query! Although it didn't help I was doing a couple of foolish things!

Thank goodness you added this repo & blog... good shout on the MSW library too