Home > database >  How can I make my token refresh function more testable?
How can I make my token refresh function more testable?

Time:04-20

I have a function in my React app's App component that refreshes the user's access token when it's first rendered (useEffect hook). At the moment, unit tests are checking to see how the state has changed at the end of the component's rendering. How can I make the function itself more testable?

I've considered refactoring to have the dispatch() hook, logout() reducer, and local setLoading() state function passed into the function as arguments so they can be mocked/so the function can be externalized from the component itself, but I'm not sure what value this would bring.

I understand that 100% test coverage is not necessary, but I'm learning and want to do the best I can while I do so.

A little context:
The app uses a ReduxToolkit slice for authentication state, including the user object and access token for the currently authenticated user, or nulls for guest users.

Auto refresh logic is implemented into a custom fetchBaseQuery.

The code below describes refreshing the access token for a user who's logged in and has a refresh token in localStorage, but has refreshed the page, clearing the redux state. It refreshes the accessToken before rendering any routes/views to avoid the user having to enter credentials every time the page refreshes.

Here's the current implementation:

//imports
...


const App = () => {
  const dispatch = useAppDispatch();
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const refresh = async () => {
      const token = localStorage.getItem("refreshToken");
      if (token) {
        const refreshRequest = {
          refresh: token,
        };

        const response = await fetch(
          `${process.env.REACT_APP_API_URL}/auth/refresh/`,
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(refreshRequest),
          }
        );

        if (response.status === 200) { // This branch gets no test coverage and I can't figure out how to fix that.
          const data: RefreshResponse = await response.json();
          // Should this be passed into the function to make it more reusable/testable?
          dispatch(
            setCredentials({ user: data.user, token: data.accessToken })
          );
        }
      }
      // Should this be passed into the function to make it more reusable/testable?
      setLoading(false); 
    };
    refresh();
  }, [dispatch]);

  if (loading) return (
    <div className="h-100 d-flex justify-content-center align-items-center bg-dark">
      <Spinner animation="border" />
    </div>
  );

  return (
    <>
      <Routes>
        // Routes
      </Routes>
    </>
  );
}

export default App;

and here are the relevant test cases:

  it("should successfully request refresh access token on render", async () => {
    // refresh() expects a refreshToken item in localStorage
    localStorage.setItem("refreshToken", "testRefreshToken");
    // I can't use enzyme because I'm on react 18, so no shallow rendering afaik :/
    // renderWithProviders renders including a redux store with auth/api reducers
    const { store } = renderWithProviders(
      <MemoryRouter>
        <App />
      </MemoryRouter>
    );

    await waitFor(() => {
      expect(store.getState().auth.token).toBe("testAccessToken");
    });

    localStorage.removeItem("refreshToken");
  });

  it("should fail to request refresh access token on render", async () => {
    localStorage.setItem("refreshToken", "testRefreshToken");
    
    // msn api route mocking, force a 401 error rather than the default HTTP 200 impl
    server.use(
      rest.post(
        `${process.env.REACT_APP_API_URL}/auth/refresh/`,
        (req, res, ctx) => {
          return res(ctx.status(401));
        }
      )
    );

    const { store } = renderWithProviders(
      <MemoryRouter>
        <App />
      </MemoryRouter>
    );

    await waitFor(() => {
      expect(store.getState().auth.token).toBeNull();
    });

    localStorage.removeItem("refreshToken");
  });

  it("should not successfully request refresh access token on render", async () => {
    const { store } = renderWithProviders(
      <MemoryRouter>
        <App />
      </MemoryRouter>
    );
    await waitFor(() => {
      expect(store.getState().auth.token).toBe(null);
    });
  });

CodePudding user response:

My suggestions:

  1. Move dispatch, useState and useEffect to custom hook. It can look like:
    const useTockenRefresh() { // Name of the custom hook can be anything that is 
    started from work 'use'
      const dispatch = useAppDispatch();
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
         /* useEffect code as is */
      }, [/* deps */])
      return loading
    }
    export default useTockenRefresh
    
  2. Utilize useTockenRefresh in your component
    const App = () => {
      const loading = useTockenRefresh()
    
      if (loading) return (
      // And rest of your code
    }
    
    

Now it is possible to test only useTockenRefresh in isolation. I would suggest to use React Hooks Testing Library for this purpose. And as this will be Unit Tests, it is better to mock everything external, like useAppDispatch, fetch, etc.

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

// Path to useTockenRefresh should be correct relative to test file
// This mock mocks default export from useTockenRefresh
jest.mock('./useTockenRefresh', () => jest.fn())
// This mock for the case when useAppDispatch is exported as named export, like
// export const useAppDispatch = () => { ... }
jest.mock('./useAppDispatch', () => ({
  useAppDispatch: jext.fn(),
}))
// If fetch is in external npm package
jest.mock('fetch', () => jest.fn())
jest.mock('./setCredentials', () => jest.fn())
// Mock other external actions/libraries here


it("should successfully request refresh access token on render", async () => {  
  // Mock dispatch. So we will not update real store, but see if dispatch has been called with right arguments
  const dispatch = jest.fn()
  useAppDispatch.mockReturnValueOnce(dispatch)
  const json = jest.fn()
  fetch.mockReturnValueOnce(new Promise(resolve => resolve({ status: 200, json, /* and other props */ })
  json.mockReturnValueOnce(/* mock what json() should return */)

  // Execute hook
  await act(async () => {
    const { rerender } = renderHook(() => useTockenRefresh())
    return rerender()
  })

  // Check that mocked actions have been called
  expect(fetch).toHaveBeenCalledWith(
    `${process.env.REACT_APP_API_URL}/auth/refresh/`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(refreshRequest),
    })
  expect(setCredentials).toHaveBeenCalledWith(/* args of setCredentials from mocked responce object */
  // And so on
}
  • Related