Subject Under Test
A utility hook provides an HTTP error handler that sends errors to a message context. A message snack bar component would show errors in toasters for end users.
Behaviours
it takes an optional message to be prepended to error messages returned from the server
it takes an optional translator for localisation
it clears the JWT token if the server returns 401 Unauthorised Error
it sends an error alerting the user to log in to an account with the required permissions if the server returns 403 Unauthorised Error
it sends extract error messages from
response.data
when applicableit sends "Server connection failed" if no response is received
if the above fails, it logs out error as-is in the console
Code
import { act } from '@testing-library/react-hooks'; | |
import axios, { AxiosError } from 'axios'; | |
import { useErrorHandler } from './useErrorHandler'; | |
import { MessageProvider, useMessage } from '../message/MessageContext'; | |
import { localStorageKey } from './useClient'; | |
import { render } from '@testing-library/react'; | |
import userEvent from '@testing-library/user-event'; | |
import { environment } from '@epic/environments'; | |
jest.mock('axios'); | |
jest.mock('@epic/environments'); | |
afterAll(() => { | |
jest.clearAllMocks(); | |
}); | |
function TestComponent() { | |
const { errorHandler } = useErrorHandler({ message: 'Oops!' }); | |
const sendRequest = async () => { | |
try { | |
await axios.get('/test'); | |
} catch (e) { | |
errorHandler(e as AxiosError); | |
} | |
}; | |
const { message } = useMessage(); | |
return ( | |
<div> | |
<button onClick={() => sendRequest()}>Send Request</button> | |
{message?.message ? <div data-testid={'error-message'}>{message?.message}</div> : null} | |
{message?.type ? <div data-testid={'error-type'}>{message?.type}</div> : null} | |
</div> | |
); | |
} | |
function setup() { | |
const mockedAxios = axios as jest.Mocked<typeof axios>; | |
const queries = render( | |
<MessageProvider> | |
<TestComponent /> | |
</MessageProvider> | |
); | |
return { ...queries, mockedAxios }; | |
} | |
describe('Error Handler', function () { | |
const token = 'I am a token'; | |
beforeEach(() => { | |
localStorage.setItem(localStorageKey, token); | |
}); | |
it('should remove token when authentication is failed', async function () { | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
response: { | |
status: 401, | |
statusText: 'Unauthorized', | |
data: { | |
message: 'Unauthorized', | |
}, | |
}, | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toMatch(/login failed/i); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(null); | |
}); | |
it('should alert to login in to an account with permission when authorisation fails', async function () { | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
response: { | |
status: 403, | |
statusText: 'Unauthorized', | |
data: { | |
message: 'Unauthorized', | |
}, | |
}, | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toContain('sign in with an account with the required permissions'); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(token); | |
}); | |
it('should return error detail, prepended with custom message', async function () { | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
response: { | |
status: 400, | |
statusText: 'Bad Request', | |
data: { | |
detail: 'Unable to process request', | |
errors: ['Invalid request'], | |
}, | |
}, | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toBe('Oops! Unable to process request'); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(token); | |
}); | |
it('should return concatenated errors messages if error detail is absent, prepended with custom message', async function () { | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
response: { | |
status: 400, | |
statusText: 'Bad Request', | |
data: { | |
errors: ['Invalid request', 'Invalid Id'], | |
}, | |
}, | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toBe('Oops! Invalid request, Invalid Id'); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(token); | |
}); | |
it('should return serialised data object if error detail and errors are absent', async function () { | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
response: { | |
status: 400, | |
statusText: 'Bad Request', | |
data: { | |
message: 'Invalid request', | |
}, | |
}, | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toMatchInlineSnapshot( | |
`"Oops! {\\"message\\":\\"Invalid request\\"}"` | |
); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(token); | |
}); | |
it('should alert server connection failed if no response was received', async function () { | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
request: {}, | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toMatchInlineSnapshot(`"Server connection failed"`); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(token); | |
}); | |
it(`if these is no response/request properties in the returned error, in development mode, | |
1. should alert message on error object, | |
2. should log error json. | |
3. should print error in the console`, async function () { | |
jest.spyOn(console, 'error').mockImplementation(() => {}); | |
jest.spyOn(console, 'log').mockImplementation(() => {}); | |
environment.production = false; | |
const { getByText, findByTestId, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
message: 'Invalid request', | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
const message = await findByTestId('error-message'); | |
const type = await findByTestId('error-type'); | |
expect(message.innerHTML).toMatchInlineSnapshot(`"Invalid request"`); | |
expect(type.innerHTML).toBe('error'); | |
expect(localStorage.getItem(localStorageKey)).toBe(token); | |
expect(console.log).toHaveBeenCalledWith(`{"message":"Invalid request"}`); | |
expect(console.error).toHaveBeenCalledWith({ | |
message: 'Invalid request', | |
}); | |
jest.restoreAllMocks(); | |
}); | |
it(`if these is no response/request properties in the returned error, in production mode, | |
1. should not alert message on error object, | |
2. should not log error json. | |
3. should print error in the console`, async function () { | |
jest.spyOn(console, 'error').mockImplementation(() => {}); | |
environment.production = true; | |
const { getByText, mockedAxios } = setup(); | |
mockedAxios.get.mockRejectedValueOnce({ | |
message: 'Invalid request', | |
} as AxiosError); | |
act(() => { | |
userEvent.click(getByText(/Send Request/i)); | |
}); | |
await waitFor(() => { | |
expect(console.error).toHaveBeenCalledWith({ | |
message: 'Invalid request', | |
}); | |
}); | |
jest.restoreAllMocks(); | |
}); | |
}); |
import { AxiosError, AxiosResponse } from 'axios'; | |
import { environment } from '@epic/environments'; | |
import { useCallback } from 'react'; | |
import { useMessage } from '../message/MessageContext'; | |
import { concatValues } from '../utils/concatValues'; | |
import { localStorageKey } from './useClient'; | |
type Translator = (english?: string) => string | undefined; | |
function extractErrorMessage(response: AxiosResponse): string { | |
if (response.data.detail) { | |
return response.data.detail; | |
} | |
if (response.data && response.data.errors) { | |
return concatValues(response.data.errors); | |
} | |
return JSON.stringify(response.data); | |
} | |
export function useErrorHandler({ | |
message, | |
t = (english?: string) => english, | |
}: { | |
message?: string; | |
t?: Translator; | |
} = {}) { | |
const { sendError } = useMessage(); | |
const processResponseError = useCallback( | |
({ response, message, t }: { response: AxiosResponse; message?: string; t: Translator }) => { | |
if (response?.status === 401) { | |
localStorage.removeItem(localStorageKey); | |
sendError(t('Login failed')); | |
window.location.assign(window.location.href); | |
return; | |
} | |
if (response?.status === 403) { | |
sendError( | |
t('Authorisation failed. Please sign in with an account with the required permissions.') | |
); | |
return; | |
} | |
sendError(`${message} ${t(extractErrorMessage(response))}`.trim()); | |
return; | |
}, | |
[sendError] | |
); | |
const errorHandler = useCallback( | |
(error: AxiosError<any>) => { | |
if (error.response) { | |
// The request was made and the server responded with a status code | |
// that falls out of the range of 2xx | |
return processResponseError({ response: error.response, message, t }); | |
} | |
if (error.request) { | |
// The request was made but no response was received | |
// `error.request` | |
// is an instance of XMLHttpRequest in the browser and an instance of | |
// http.ClientRequest in node.js | |
sendError(t(`Server connection failed`)); | |
return; | |
} | |
if (!environment.production) { | |
// Something happened in setting up the request that triggered an Error | |
sendError(t(error.message)); | |
console.log(JSON.stringify(error)); | |
} | |
console.error(error); | |
}, | |
[message, processResponseError, sendError, t] | |
); | |
return { errorHandler }; | |
} |
Notes
TestComponent
shows a way the error handler hook could be used. It is a component designed to facilitate the tests.setup
function mocksaxios
and renders the above component inside aMessageProvider
which is not mocked.userEvent
is used to trigger the HTTP call, which was mocked to reject with an error object. It has to be wrapped inside anact
block as it updates the states in the message context.findBy
queries from@testing-library
is async by design, and we do not have to do anything extra in the tests to wait for async operations.in the last test, I use
waitFor
from@testing-library
as there is nothing be found byfindBy
. Note: do not forget to await forwaitFor
as I did.
Top comments (2)
Thanks ... testing is often neglected and its hard to find good reading material on it .. appreciated :)
You are welcome. I am glad you liked it. There are a lot of misconceptions out there for testing, but also loads of good ones. Just that if one doesn’t have a good understanding of the principles of testing, it is not easy to recognise one.
If you look for a good testing book to read, read this one: manning.com/books/unit-testing. I think principles are at the core of effective testing.
I know testing is important but have been putting it off for so long, because I didn’t have a concrete understanding of what’s the correct way and to be frank I was confused. The book helped me tremendously.