如何使我的令牌刷新功能更易于测试?
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);
});
});
我的建议:
- 将
dispatch
、useState
和 useEffect
移动到自定义挂钩。它看起来像:
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
- 在您的组件中使用
useTockenRefresh
const App = () => {
const loading = useTockenRefresh()
if (loading) return (
// And rest of your code
}
现在可以单独测试 useTockenRefresh
。为此,我建议使用 React Hooks Testing Library。由于这将是单元测试,因此最好模拟所有外部内容,例如 useAppDispatch
、fetch
等
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
}
我的 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);
});
});
我的建议:
- 将
dispatch
、useState
和useEffect
移动到自定义挂钩。它看起来像: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
- 在您的组件中使用
useTockenRefresh
const App = () => { const loading = useTockenRefresh() if (loading) return ( // And rest of your code }
现在可以单独测试 useTockenRefresh
。为此,我建议使用 React Hooks Testing Library。由于这将是单元测试,因此最好模拟所有外部内容,例如 useAppDispatch
、fetch
等
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
}