10

I'm trying to test the following scenario:

  • A user with an expired token tries to access a resource he is not authorized
  • The resources returns a 401 error
  • The application updates a global state "isExpiredSession" to true

For this, I have 2 providers:

  • The authentication provider, with the global authentication state
  • The one responsible to fetch the resource

There are custom hooks for both, exposing shared logic of these components, i.e: fetchResource/expireSesssion

When the resource fetched returns a 401 status, it sets the isExpiredSession value in the authentication provider, through the sharing of a setState method.

AuthenticationContext.js import React, { createContext, useState } from 'react';

const AuthenticationContext = createContext([{}, () => {}]);

const initialState = {
  userInfo: null,
  errorMessage: null,
  isExpiredSession: false,
};

const AuthenticationProvider = ({ authStateTest, children }) => {
  const [authState, setAuthState] = useState(initialState);

  return (
    <AuthenticationContext.Provider value={[authStateTest || authState, setAuthState]}>
      { children }
    </AuthenticationContext.Provider>);
};


export { AuthenticationContext, AuthenticationProvider, initialState };

useAuthentication.js

import { AuthenticationContext, initialState } from './AuthenticationContext';


const useAuthentication = () => {
  const [authState, setAuthState] = useContext(AuthenticationContext);
  ...
  const expireSession = () => {
    setAuthState({
      ...authState,
      isExpiredSession: true,
    });
  };
  ...
  return { expireSession };
 }

ResourceContext.js is similar to the authentication, exposing a Provider

And the useResource.js has something like this:

const useResource = () => {
  const [resourceState, setResourceState] = useContext(ResourceContext);
  const [authState, setAuthState] = useContext(AuthenticationContext);

  const { expireSession } = useAuthentication();

  const getResource = () => {
    const { values } = resourceState;
    const { userInfo } = authState;

    return MyService.fetchResource(userInfo.token)
      .then((result) => {
        if (result.ok) {
          result.json()
            .then((json) => {
              setResourceState({
                ...resourceState,
                values: json,
              });
            })
            .catch((error) => {
              setErrorMessage(`Error decoding response: ${error.message}`);
            });
        } else {
          const errorMessage = result.status === 401 ?
            'Your session is expired, please login again' :
            'Error retrieving earnings';
          setErrorMessage(errorMessage);
          expireSession();

        }
      })
      .catch((error) => {
        setErrorMessage(error.message);
      });
  };
  ...

Then, on my tests, using react-hooks-testing-library I do the following:

  it.only('Should fail to get resource with invalid session', async () => {
    const wrapper = ({ children }) => (
      <AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
        <ResourceProvider>{children}</ResourceProvider>
      </AuthenticationProvider>
    );
    const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });

    fetch.mockResponse(JSON.stringify({}), { status: 401 });

    act(() => result.current.getResource());
    await waitForNextUpdate();

    expect(result.current.errorMessage).toEqual('Your session is expired, please login again');
    // Here is the issue, how to test the global value of the Authentication context? the line below, of course, doesn't work
    expect(result.current.isExpiredSession).toBeTruthy();
  });

I have tried a few solutions:

  • Rendering the useAuthentication on the tests as well, however, the changes made by the Resource doesn't seem to reflect on it.
  • Exposing the isExpiredSession variable through the Resource hook, i.e:
      return { 
            ...
            isExpiredSession: authState.isExpiredSession,
            ...
       };

I was expecting that by then this line would work:

expect(result.current.isExpiredSession).toBeTruthy();

But still not working and the value is still false

Any idea how can I implement a solution for this problem?

dfranca
  • 5,156
  • 2
  • 32
  • 60

1 Answers1

3

Author of react-hooks-testing-library here.

It's a bit hard without being able to run the code, but I think your issue might be the multiple state updates not batching correctly as they are not wrapped in an act call. The ability to act on async calls is in an alpha release of react (v16.9.0-alpha.0) and we have an issue tracking it as well.

So there may be 2 ways to solve it:

  1. Update to the alpha version and a move the waitForNextUpdate into the act callback
npm install react@16.9.0-alpha.0
  it.only('Should fail to get resource with invalid session', async () => {
    const wrapper = ({ children }) => (
      <AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
        <ResourceProvider>{children}</ResourceProvider>
      </AuthenticationProvider>
    );
    const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });

    fetch.mockResponse(JSON.stringify({}), { status: 401 });

    await act(async () => {
      result.current.getResource();
      await waitForNextUpdate();
    });

    expect(result.current.errorMessage).toEqual('Your session is expired, please login again');

    expect(result.current.isExpiredSession).toBeTruthy();
  });
  1. Add in a second waitForNextUpdate call
  it.only('Should fail to get resource with invalid session', async () => {
    const wrapper = ({ children }) => (
      <AuthenticationProvider authStateTest={{ userInfo: { token: 'FOOBAR' }, isExpiredSession: false }}>
        <ResourceProvider>{children}</ResourceProvider>
      </AuthenticationProvider>
    );
    const { result, waitForNextUpdate } = renderHook(() => useResource(), { wrapper });

    fetch.mockResponse(JSON.stringify({}), { status: 401 });

    act(() => result.current.getResource());

    // await setErrorMessage to happen
    await waitForNextUpdate();

    // await setAuthState to happen
    await waitForNextUpdate();

    expect(result.current.errorMessage).toEqual('Your session is expired, please login again');

    expect(result.current.isExpiredSession).toBeTruthy();
  });

Your appetite for using alpha versions will likely dictate which option you go for, but, option 1 is the more "future proof". Option 2 may stop working one day once the alpha version hits a stable release.

Michael Peyper
  • 6,814
  • 2
  • 27
  • 44
  • Hi, Thanks for the anwer. I have tried updating to the react alpha, however even after updating and changing my code, the error is still there. Also, I get some warnings... if I just move the `waitForNextUpdate` into `act` like the code you posted I get a warning informing me that await must be inside an async function. However, if I change the act callback to async I get a warning informing that the act method callback cannot return a Promise. – dfranca Jun 11 '19 at 15:11
  • 1
    oh yeah, I forgot about that part of it. I've updated the example to show the async `act` call correctly. – Michael Peyper Jun 11 '19 at 22:14