testing-library/react-testing-library

No `waitForNextUpdate` ?

kamilzielinskidev opened this issue ยท 8 comments

Hi all,

I've briefly checked the docs and discord to check that and it sees that waitForNextUpdate is not available as return of renderHook for now in testing library.
Is there a plan to implement that or testing async hooks is considered as bad practice?

If it won't be implemented maybe theres an option to inform about it in the docs, as it seems to be main feature for testing async hooks

Thank you for what you do with the lib.

Is there a plan to implement that or testing async hooks is considered as bad practice?

the next update isn't necessarily relevant to the user. We want to discourage testing these implementation in Testing Library. waitFor has all the tools you need.

@eps1lon is right on the waitFor, though since we are testing hooks, sometimes it might be harder to test what is relevent to a user since testing a hook is arguably in some way testing implementation details.

Here is an example of an upgrade I did (code simplified).

From:

import { renderHook } from '@testing-library/react-hooks';

describe(`Search users`, () => {
  let store: Store;

  const searchTerm = 'test';

  beforeEach(() => {
    store = createTestStore();
  });

  it(`searches for users`, async () => {
    const wrapper = <ReduxProvider store={store}>{children}</ReduxProvider>
    const { result, waitForNextUpdate } = renderHook(() => useSearchUsers(searchTerm), { wrapper });

    expect(result.current).toMatchObject({ users: [], isFetching: true });

    // OLD WAY
    await waitForNextUpdate();

    expect(result.current).toMatchObject({ users: [], isFetching: false });

    // assert more...
  });
});

To:

import { renderHook, waitFor } from '@testing-library/react';

describe(`Search users`, () => {
  let store: Store;

  const searchTerm = 'test';

  beforeEach(() => {
    store = createTestStore();
  });

  it(`searches for users`, async () => {
    const wrapper = <ReduxProvider store={store}>{children}</ReduxProvider>
    const { result } = renderHook(() => useSearchUsers(searchTerm), { wrapper });

    expect(result.current).toMatchObject({ users: [], isFetching: true });
    
    // NEW WAY
    await waitFor(() => expect(result.current).toMatchObject({ users: [], isFetching: false }));

    // assert more...
  });
});

This is my previous test on Apollo's useQuery using waitForNextUpdate:

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

test('useQuery', async () => {
  const wrapper = ({ children }) => <ApolloProvider client={createMockApolloClient()}>{children}</ApolloProvider>

  const { result, waitForNextUpdate } = renderHook(
    () =>
      useQuery(GET_ORGANIZATION_PACKS_API, {
        variables: { orgUid },
        fetchPolicy: 'no-cache',
        notifyOnNetworkStatusChange: true,
      }),
    { wrapper },
  )

  let { data, loading, error, refetch, networkStatus } = result.current
  expect(loading).toBeTrue()
  expect(networkStatus).toBe(NetworkStatus.loading)
  expect(error).toBeUndefined()
  expect(data).toBeUndefined()
  expect(refetch).toBeFunction()

  await waitForNextUpdate({ timeout: 5000 })
  ;({ data, loading, error, refetch, networkStatus } = result.current)

  expect(error).toBeUndefined()
  expect(loading).toBeFalse()
  expect(networkStatus).toBe(NetworkStatus.ready)
  expect(data).toMatchSnapshot({}, 'GET_ORGANIZATION_PACKS_API')
  expect(refetch).toBeFunction()
  refetch().then()

  await waitForNextUpdate()

  const data0 = data
  ;({ data, loading, error, refetch, networkStatus } = result.current)

  expect(error).toBeUndefined()
  expect(loading).toBeTrue()
  expect(networkStatus).toBe(NetworkStatus.refetch)
  expect(data).toEqual(data0)
  expect(refetch).toBeFunction()

  await waitForNextUpdate()
  ;({ data, loading, error, refetch, networkStatus } = result.current)

  expect(error).toBeUndefined()
  expect(loading).toBeFalse()
  expect(networkStatus).toBe(NetworkStatus.ready)
  expect(data).toEqual(data0)
  expect(refetch).toBeFunction()
})

const GET_ORGANIZATION_PACKS_API = gql`...`

It was working well.


Now, how do I write it w/o waitForNextUpdate?

@mirismaili you can use waitFor for that.

What about trying:

  waitFor(() => {
      expect(loading).toBeFalse()
  }, {timeout: 5000});
  waitFor(() => {
    expect(networkStatus).toBe(NetworkStatus.ready)
  }, {timeout: 5000});

That should work.

To mock apollo queries and make it work in test

  it('should return the mocked data', async () => {
    const { result } = setUp()

    await waitFor(() => {
      expect(result.current.loading).toBeFalsy()
    })

    await waitFor(() => {
      expect(result.current.data).toStrictEqual({
        recentlyViewed: [recentlyViewed]
      })
    })
  })

waitFor + expect can break expect.assertions() expectations, as it may result in more assertions than expected due to retries

I don't see how waitFor is a replacement for waitForNextUpdate at all. waitFor doesn't say anything about how many re-renders happened before I got there. The argument here seems to be that the internals of the hook and its behavior are a black box, and I should only test the dom. Nice idea, but it's critical that I understand and control how many render cycles are triggered by my hook. Is that possible under the new paradigm?

By way of example: I've just spent several days implementing some hooks based around Tanstack Query with selectors/transformations to ensure that consumers of the hook are only reloaded when their relevant slice of the underlying data is mutated (a la useContextSelector). It's a critical performance consideration for my app. How can I test that with this library?

Well, here's one way:

import {
  queries,
  Queries,
  renderHook,
  RenderHookOptions,
  RenderHookResult
} from '@testing-library/react'

export interface MyRenderHookResult<Result, Props> extends RenderHookResult<Result, Props> {
  renders: MutableRefObject<Result[]>
}

export function myRenderHook<
  Result,
  Props,
  Q extends Queries = typeof queries,
  Container extends Element | DocumentFragment = HTMLElement,
  BaseElement extends Element | DocumentFragment = Container,
>(
  render: (initialProps: Props) => Result,
  options?: RenderHookOptions<Props, Q, Container, BaseElement>,
): MyRenderHookResult<Result, Props> {

  const renders = {current: [] as Result[]}

  const _render = (props: Props) => {
    const returnValue = render(props)
    renders.current.push(returnValue)
    return returnValue
  }

  const result = renderHook(_render, options)

  return {
    ...result,
    renders
  }
}

...

test('it renders real nice', () => {
  const {result, renders} = myRenderHook(() => myHook())
  await waitFor(() => {
    expect(result.current).toEqual('ball python $49')
    expect(renders.current.length).toEqual(2)
  })
})