How can I make my token refresh function more testable? - reactjs

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

My suggestions:
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
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

test component doesn't get redux updates with useSelector (react-testing-library)

I'm trying to write a test (with react-testing-library) for a component that makes an api call inside a useEffect and then updates redux state. The component should then update with the new state, but in my test, the component never seems to get updates to redux state.
I've tried logging at each step of the process: I found that the redux state was updating as expected during the batched actions in apiCall(). Then when "otherReduxAction" is called, it seems to be acting on a stale store. Moving that action out of the component fixed the problem. Same with the subsequent "setIsFetching" call. However, while moving those actions out of the component helped to persist state changes, the component still never gets the state updates.
This behavior only exists in testing. The app updates as expected.
component:
const CallsList = () => {
const dispatch = useDispatch();
const { filteredCalls, isFetching } = useSelector(selectState);
useEffect(() => {
async function fetchRecordings() {
dispatch(setIsFetching(true));
try {
await apiCall();
dispatch(otherReduxAction());
} catch (err) {}
dispatch(setIsFetching(false));
}
fetchRecordings();
}, [dispatch]);
return (
isFetching ? (
RENDER PROGRESS BAR
) : filteredCalls.length ? (
RENDER LIST OF CALLS
) : (
RENDER NO RESULTS MESSAGE
)
);
};
apiCall:
export const apiCall = (): Promise<string> => {
return new Promise(async (res, rej) => {
try {
const response = await axios.get<GetResponse>(
`${process.env.REACT_APP_API_BASE_URL}/recordings`,
);
batch(() => {
store.dispatch(setCalls(response.data.recordings));
... DISPATCH ADDITIONAL ACTIONS
});
res("success");
} catch (err) {
rej("error");
}
});
};
msw handler:
rest.get(`${process.env.REACT_APP_API_BASE_URL}/recordings`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({ MOCKED RESPONSE OBJECT })
);
}),
custom react-testing-library render:
import { render as rtlRender } from "#testing-library/react";
...
export const render = (ui: any, initialStore = {}, { route = '/' } = {}, options = {}, ) => {
const store = createStore(rootReducer, initialStore);
const Providers = ({ children }: any) => (
<Provider store={store}>
<BrowserRouter>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</BrowserRouter>
</Provider>
);
window.history.pushState({}, 'Test page', route)
return rtlRender(ui, { wrapper: Providers, ...options });
};
test (this fails on awaiting the element with LIST ITEM TEXT:
describe("CallsList", () => {
it("renders list of recordings", async () => {
render(<CallsList />);
const progressBar = screen.getByRole("progressbar");
expect(progressBar).toBeInTheDocument();
await waitForElementToBeRemoved(circularProgress)
expect(await screen.findByText(LIST ITEM TEXT)).toBeInTheDocument();
});
});

Mocked useHistory is not called in async event handler

Summary
I'm writing test code for my react app, but somehow, it always fails.
My app code is very simple, there is only one button, and if it's clicked, a function handleSubmit is fired.
What the handler does are
Fetching data from backend(This is async function)
Move to /complete page.
What I did
I mocked the function fetching data from API in test code
I mocked the useHistory in test code
Note
I realized that if the line that is fetching data from API is commented out, the test will pass.
Code
My main app code
import { useFetchDataFromAPI } from '#/usecase/useFetchDataFromAPI';
:
const { fetchDataFromAPI } = useFetchDataFromAPI();
:
const handleSubmit = async () => {
// If the line below is not commented out, test will fail
// const { id } = await fetchDataFromAPI();
history.push(`/complete`);
};
return (
<>
<button onClick={handleSubmit}>Button</button>
</>
My test code
:
jest.mock('#/usecase/useFetchDataFromAPI', () => ({
useFetchDataFromAPI: () => {
return { fetchDataFromAPI: jest.fn((): number => {
return 1;
})}
}
}));
const mockHistoryPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom') as any,
useHistory: () => ({
push: mockHistoryPush,
}),
}));
:
const renderApplicationWithRouterHistory = () => {
const history = createMemoryHistory();
const wrapper = render(
<Router history={history}>
<Application />
</Router>
);
return { ...wrapper, history };
};
:
describe('Test onClick handler', async () => {
test('Submit', () => {
const { getByText, getByRole } = renderApplication();
const elementSubmit = getByText('Button');
expect(elementSubmit).toBeInTheDocument();
fireEvent.click(elementSubmit);
expect(mockHistoryPush).toHaveBeenCalled();
});
});
Your event handler is called on button click, but because it is asynchronous, its result is not evaluated until after your test runs. In this particular case, you don't need the async behavior, so just use:
const handleSubmit = () => {
history.push(`/complete`)
}
testing-library provides a method waitFor for this if your handler did need to await something:
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalled())
Though another simple way is to simply await a promise in your test so that the expectation is delayed by a tick:
fireEvent.click(elementSubmit);
await Promise.resolve();
expect(mockHistoryPush).toHaveBeenCalled();

Mock a custom service with jest and enzyme for a react component

I have a simple component that loads up some users from an api call, which I have abstracted in a service. Here is the Component:
export const Dashboard: FunctionComponent = () => {
const [users, setUsers] = useState<IUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const userService: UserService = UserService.get();
useEffect(() => {
userService.getUsers()
.then((data) => {
console.log(data)
setIsLoading(false)
setUsers(data)
})
.catch((error) => {
console.log("Could not load users: ", error);
setIsLoading(false)
setUsers([]);
});
}, []);
return (
isLoading
?
<div data-testid="loading">
<h4>Loading...</h4>
</div>
:
<div data-testid="users">
<UserList users={users}/>
</div>
);
}
export default Dashboard;
And my service looks like this:
export class UserService {
private static INSTANCE = new UserService();
private constructor() {}
public static get(): UserService {
return UserService.INSTANCE;
}
public async getUsers(): Promise<IUser[]> {
const response = await axios.get("api/users");
return response.data as IUsers[];
}
}
The reason I have extracted it in a .ts file is that I am planing to be reusing this service in another component and also add here other api calls.
So now I want to write a simple test for my Dashboard component, where I mock the UserService to return a promise and then test that my data-testid=users is rendered.
Here is my test:
configure({adapter: new Adapter()});
describe("User dashboard component", () => {
let userService: UserService;
const users = [
{
id: "0c8593e8-8fa6-4d40-b555-5ef812477c70",
name: "John",
age: 25
}
];
beforeAll(() => {
userService = UserService.get();
});
test("renders component", () => {
userService.getUsers = () => {
return Promise.resolve(users);
};
const dashboard = shallow(<Dashboard />);
expect(dashboard.find(<Dashboard />)).toBeTruthy();
expect(dashboard.find('[data-testid="users"]').length).toEqual(1);
expect(toJson(dashboard)).toMatchSnapshot();
});
test("loading", () => {
const dashboard = shallow(<Dashboard />);
expect(dashboard.find('[data-testid="loading"]').length).toEqual(1);
expect(toJson(dashboard)).toMatchSnapshot();
});
});
I don't want to mock the useState hook, but apparently my part where I resolve a Promise with users does nothing.
How do I achieve that? what is the best practice here? Thanks!
shallow doesn't support useEffect at this moment, mount should be used instead.
The component is rendered asynchronously and the test should be asynchronous too. Mocking methods by assignment is a bad practice because they cannot be restored, this results in test cross-contamination. A promise that makes it asynchronous should be exposed for chaining. In case of a spy it can be retrieved via Jest spy API.
It should be:
jest.spyOn(userService, 'getUsers').mockImplementation(() => Promise.resolve(users));
const dashboard = mount(<Dashboard />);
expect(userService.getUsers).toBeCalledTimes(1);
await act(async () => {
await userService.getUsers.mock.results[0].value;
});
...
Spies should be restored and cleared between tests in order for tests to not affect each other.

React - how do I unit test an API call in Jest?

I have a bunch of API calls that I would like to unit test. As far as I know, unit testing API calls doesn't involve actually making those API calls. As far as I know you would simulate responses of those API calls and then test on the DOM changes however I'm currently struggling to do this. I have the following code:
App.js
function App() {
const [text, setText] = useState("");
function getApiData() {
fetch('/api')
.then(res => res.json())
.then((result) => {
console.log(JSON.stringify(result));
setText(result);
})
}
return (
<div className="App">
{/* <button data-testid="modalButton" onClick={() => modalAlter(true)}>Show modal</button> */}
<button data-testid="apiCall" onClick={() => getApiData()}>Make API call</button>
<p data-testid="ptag">{text}</p>
</div>
);
}
export default App;
App.test.js
it('expect api call to change ptag', async () => {
const fakeUserResponse = {'data': 'response'};
var {getByTestId} = render(<App />)
var apiFunc = jest.spyOn(global, 'getApiData').mockImplementationOnce(() => {
return Promise.resolve({
json: () => Promise.resolve(fakeUserResponse)
})
})
fireEvent.click(getByTestId("apiCall"))
const text = await getByTestId("ptag")
expect(text).toHaveTextContent(fakeUserResponse['data'])
})
I'm trying to mock the result of getApiData() here and then test a DOM change (the p tag changes to the result). The above code gives me the error:
Cannot spy the getApiData property because it is not a function; undefined given instead
How do I access that class function?
EDIT:
I've adapted the code but I'm still having a bit of trouble:
App.js
function App() {
const [text, setText] = useState("");
async function getApiData() {
let result = await API.apiCall()
console.log("in react side " + result)
setText(result['data'])
}
return (
<div className="App">
{/* <button data-testid="modalButton" onClick={() => modalAlter(true)}>Show modal</button> */}
<button data-testid="apiCall" onClick={() => getApiData()}>Make API call</button>
<p data-testid="ptag">{text}</p>
</div>
);
}
export default App;
apiController.js
export const API = {
apiCall() {
return fetch('/api')
.then(res => res.json())
}
}
Server.js
const express = require('express')
const app = express()
const https = require('https')
const port = 5000
app.get('/api', (request, res) => {
res.json("response")
})
app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`))
App.test.js
import React from 'react';
import { render, shallow, fireEvent } from '#testing-library/react';
import App from './App';
import {API} from './apiController'
//import shallow from 'enzyme'
it('api call returns a string', async () => {
const fakeUserResponse = {'data': 'response'};
var apiFunc = jest.spyOn(API, 'apiCall').mockImplementationOnce(() => {
return Promise.resolve({
json: () => Promise.resolve(fakeUserResponse)
})
})
var {getByTestId, findByTestId} = render(<App />)
fireEvent.click(getByTestId("apiCall"))
expect(await findByTestId("ptag")).toHaveTextContent('response');
})
The error I'm getting is
expect(element).toHaveTextContent()
Expected element to have text content:
response
Received:
14 | var {getByTestId, findByTestId} = render(<App />)
15 | fireEvent.click(getByTestId("apiCall"))
> 16 | expect(await findByTestId("ptag")).toHaveTextContent('response');
| ^
17 | })
18 |
19 | // it('api call returns a string', async () => {
Reusable unit test (hopefully):
it('api call returns a string', async () => {
const test1 = {'data': 'response'};
const test2 = {'data': 'wrong'}
var apiFunc = (response) => jest.spyOn(API, 'apiCall').mockImplementation(() => {
console.log("the response " + JSON.stringify(response))
return Promise.resolve(response)
})
var {getByTestId, findByTestId} = render(<App />)
let a = await apiFunc(test1);
fireEvent.click(getByTestId("apiCall"))
expect(await findByTestId("ptag")).toHaveTextContent('response');
let b = await apiFunc(test2);
fireEvent.click(getByTestId("apiCall"))
expect(await findByTestId("ptag")).toHaveTextContent('wrong');
})
Don't mock the API library. It's better the stub the server responses instead. If you write a bunch of tests that mock out the API call, you're binding the implementation of your app to your tests. Say you don't want to use fetch() but want to use something like isomorphic-unfetch for a SSR app? Switching over an entire test suite of mocks will be really painful.
Instead, use a server stubbing library like nock or msw. Think of these libraries as JSDOM but for your server. This way you're binding your test suite to the backend rather than the implementation library. Let's rewrite your example to show you what I mean:
import React from 'react';
import nock from 'nock';
import { render, shallow, fireEvent } from '#testing-library/react';
import App from './App';
it('displays user data', async () => {
const scope = nock('https://yoursite.com')
.get('/api')
.once()
.reply(200, {
data: 'response',
});
var {getByTestId, findByTestId} = render(<App />)
fireEvent.click(getByTestId("apiCall"))
expect(await findByTestId("ptag")).toHaveTextContent('response');
})
Check out the blog post I wrote for a deeper dive on the subject, Testing components that make API calls.
You cannot access getApiData because it's a private function inside other function (a closure) and it's not exposed to the global scope. That means global variable does not have property getApiData, and you are getting undefined given instead.
To do this you need to export somehow this function, I would suggest by moving it to different file, but the same should be fine as well. Here's a simple example:
export const API = {
getData() {
return fetch('/api').then(res => res.json())
}
}
Somewhere in your component:
API.getData().then(result => setText(result))
And in test:
var apiFunc = jest.spyOn(API, 'getData').mockImplementationOnce(() => {
return Promise.resolve({
json: () => Promise.resolve(fakeUserResponse)
})
})
There are other ways to achieve that, but maybe this one would be enough.
And I think there would be one more problem. You are using const text = await getByTestId("ptag"), but getBy* functions from react-testing-library are not asynchronous (they do not return a promise you can wait to resolve), so your test will fail, as you wouldn't wait for a mock request to finish. Instead, try findBy* version of this function that you can await on and make sure promise is resolved.

Why does the 'then' part in async test fail in jest?

Component:
export const fetchList = () => {
return API.get(AMPLIFY_ENPOINTS.default, API_URLS.list, { response: true });
}
const List: React.FC = () => {
const dispatch = useDispatch();
const setError = useError();
useEffect(() => {
fetchList()
.then((response) => {
if (response && response.data?.length) {
dispatch(setList(response.data));
}
})
.catch((error) => {
setError(error);
});
}, [])
}
Test:
it('should fetch list', async () => {
const wrapper = mount(
<Provider store={store}>
<List />
</Provider>
);
API.get = jest.fn().mockImplementation(() => Promise.resolve({ data: mockList }));
const response = await fetchList();
console.log(store.getActions(), response); // HERE IS THE ISSUE
});
So the store.getActions() returns setError from catch block, why is that? It should return setList from then block. What am I doing wrong? response variable returns mockList just fine.
Edit
The error it returns is API not configured, I'm using aws amplify.
fetchList is called when the component is mounted, mocked API.get doesn't affect the first time it's called, and second call doesn't do anything. It's a bad practice to mock methods by assigning a spy to them because they cannot be restored after a test.
The problem with fetchList is that it cannot be spied or mocked because it's used in the same module it's defined. The promise it creates in useEffect cannot be chained, promises need to be flushed in order to avoid race condition.
It can be:
let flushPromises = () => new Promise(resolve => setImmediate(resolve));
jest.spyOn(API, 'get').mockResolvedValue({ data: mockList });
const wrapper = mount(
<Provider store={store}>
<List />
</Provider>
);
await flushPromises();
expect(store.getActions())...

Resources