testing-library / react-hooks-testing-library

🐏 Simple and complete React hooks testing utilities that encourage good testing practices.
https://react-hooks-testing-library.com
MIT License
5.25k stars 230 forks source link

Stateful context not updating properly #967

Closed sliu-cais closed 9 months ago

sliu-cais commented 11 months ago

Relevant code or config:

I'm having trouble testing a custom hook that uses a context that basically sets and gets values.

Here's a simplified toy version:

type State = { a?: number; b?: number; c?: number }
const defaultState: State = {}

const StateContext = React.createContext<{
  state: State
  setState: React.Dispatch<React.SetStateAction<State>>
}>({
  state: defaultState,
  setState: () => {},
})

const StateProvider = ({ children }: { children: React.ReactNode }) => {
  const [state, setState] = React.useState<State>(defaultState)
  return (
    <StateContext.Provider value={{ state, setState }}>
      {children}
    </StateContext.Provider>
  )
}

export const useStateContext = ({ context }: { context: typeof StateContext }) => {
  const { state, setState } = React.useContext(context)
  const sum = Object.values(state).reduce((a, b) => a + (b ?? 0), 0)
  return { state, setState, sum }
}

What you did:

If I implement in app it works as expected:

const Button = ({ newState }: { newState: State }) => {
  const { state, setState, sum } = useStateContext({ context: StateContext })
  useEffect(() => console.log({ state, sum }), [state, sum])
  return (
    <button
      onClick={() => {
        console.log({ newState })
        setState(newState)
      }}
    >
      {JSON.stringify(newState)}
    </button>
  )
}

export const App = () => {
  return (
      <StateProvider>
        <Button newState={{ a: 1, b: 2, c: 3 }} />
        <Button newState={{ a: 2, b: 3, c: 4 }} />
      </StateProvider>
} 

This logs out the new sum correctly when the useStateContext is used when buttons are clicked.

When I try and test though:

  it.only('useStateContext', () => {
    type State = { a?: number; b?: number; c?: number }
    const defaultState: State = {}

    const StateContext = React.createContext<{
      state: State
      setState: React.Dispatch<React.SetStateAction<State>>
    }>({
      state: defaultState,
      setState: () => {},
    })

    const StateProvider = ({ children }: { children: React.ReactNode }) => {
      const [state, setState] = React.useState<State>(defaultState)
      return (
        <StateContext.Provider value={{ state, setState }}>
          {children}
        </StateContext.Provider>
      )
    }

    const useStateContext = ({ context }: { context: typeof StateContext }) => {
      const { state, setState } = React.useContext(context)
      const sum = Object.values(state).reduce(
        (acc, curr) => acc + (curr ?? 0),
        0
      )
      return { state, setState, sum }
    }

    const {
      result: {
        current: { state, setState, sum },
      },
      rerender,
    } = renderHook(
      () => {
        return useStateContext({ context: StateContext })
      },
      { wrapper: StateProvider }
    )

    act(() => {
      setState({ a: 1, b: 2, c: 3 })
      rerender()
    })
    expect(state).toEqual({ a: 1, b: 2, c: 3 })
    expect(sum).toEqual(6)

    act(() => {
      setState({ a: 2, b: 3, c: 4 })
      rerender()
    })
    expect(state).toEqual({ a: 2, b: 3, c: 4 })
    expect(sum).toEqual(9)
  })

it never updates the state

  ● useFilterTrack › useStateContext

    expect(received).toEqual(expected) // deep equality

    - Expected  - 5
    + Received  + 1

    - Object {
    -   "a": 1,
    -   "b": 2,
    -   "c": 3,
    - }
    + Object {}

      202 |       rerender()
      203 |     })
    > 204 |     expect(state).toEqual({ a: 1, b: 2, c: 3 })
          |                   ^
      205 |     expect(sum).toEqual(6)
      206 |
      207 |     act(() => {

What happened:

Reproduction:

Codesandbox of code: https://codesandbox.io/s/frosty-maria-ccydpt?file=/src/App.tsx

The test is self contained. You should be able to copy-paste the it.only block and run.

Problem description:

I have a working stateful context that I can verify just by using the app, but cannot seem to test it properly

Suggested solution:

This works:

const Counter = () => {
  const { sum } = useStateContext({ context: StateContext });
  return <div>{sum}</div>;
};

export const App = () => {
  return (
    <StateProvider>
      <Button newState={{ a: 1, b: 2, c: 3 }} />
      <Button newState={{ a: 2, b: 3, c: 4 }} />
      <Counter />
    </StateProvider>
  );
};

But this does not, because the usage of the context is outside of the provider:

export const App = () => {
  const { sum } = useStateContext({ context: StateContext });
  return (
    <StateProvider>
      <Button newState={{ a: 1, b: 2, c: 3 }} />
      <Button newState={{ a: 2, b: 3, c: 4 }} />
      <div>{sum}</div>
    </StateProvider>
  );
};

It appears the test is behaving like the latter, so perhaps wrapper isn't wrapping correctly?

mpeyper commented 9 months ago

Hi @sliu-cais,

Sorry, I missed the notification for this issue. Did you get it sorted in the end?

one thing that immediately jumped out at me was that you are destructing result which breaks hook updates (see the note in this section of the docs).

sliu-cais commented 9 months ago

Hi @sliu-cais,

Sorry, I missed the notification for this issue. Did you get it sorted in the end?

one thing that immediately jumped out at me was that you are destructing result which breaks hook updates (see the note in this section of the docs).

I eventually went with a completely different approach so this wasn't needed in the end. But you're saying if I were to do this it would likely work, correct?

const {
      result,
      rerender,
    } = renderHook(
      () => {
        return useStateContext({ context: StateContext })
      },
      { wrapper: StateProvider }
    )

    act(() => {
      result.current.setState({ a: 1, b: 2, c: 3 })
      result.current.rerender()
    })
    expect(result.current.state).toEqual({ a: 1, b: 2, c: 3 })
    expect(result.current.sum).toEqual(6)

    act(() => {
      result.current.setState({ a: 2, b: 3, c: 4 })
      result.current.rerender()
    })
    expect(result.current.state).toEqual({ a: 2, b: 3, c: 4 })
    expect(result.current.sum).toEqual(9)

If so that's good to know, I'll keep that in mind for the future.

mpeyper commented 9 months ago

Yeah, that should work.