如何使我的令牌刷新功能更易于测试?

How can I make my token refresh function more testable?

我的 React 应用程序的 App 组件中有一个函数可以在首次呈现时刷新用户的访问令牌(useEffect 挂钩)。目前,单元测试正在检查状态在组件渲染结束时发生了怎样的变化。如何使函数本身更易于测试?

我考虑过重构以将 dispatch() 钩子、logout() reducer 和本地 setLoading() 状态函数作为参数传递给函数,这样它们就可以 mocked/so 该功能可以从组件本身外部化,但我不确定这会带来什么价值。

我知道 100% 的测试覆盖率不是必需的,但我正在学习并希望尽我所能做到最好。

一点背景:
该应用程序使用 ReduxToolkit 切片作为身份验证状态,包括当前经过身份验证的用户的用户对象和访问令牌,或访客用户的空值。

自定义 fetchBaseQuery 中实现了自动刷新逻辑。

下面的代码描述了为登录并在 localStorage 中拥有刷新令牌但已刷新页面并清除 redux 状态的用户刷新访问令牌。它会在呈现任何内容之前刷新 accessToken routes/views 以避免用户每次刷新页面时都必须输入凭据。

这是当前的实现:

//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;

这里是相关的测试用例:

  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);
    });
  });

我的建议:

  1. dispatchuseStateuseEffect 移动到自定义挂钩。它看起来像:
    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. 在您的组件中使用 useTockenRefresh
    const App = () => {
      const loading = useTockenRefresh()
    
      if (loading) return (
      // And rest of your code
    }
    
    

现在可以单独测试 useTockenRefresh。为此,我建议使用 React Hooks Testing Library。由于这将是单元测试,因此最好模拟所有外部内容,例如 useAppDispatchfetch

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
}