Subject Under Test(sut)
A HTTP client adaptor that wraps axios's get, post, patch and delete methods, adding JWT token to all requests if a token is found.
This adaptor hook exists for the following reasons:
it acts as an adaptor to
axios
, so that in the future, I could replaceaxios
without touching any other codeit adds base URL, JWT and default headers to HTTP calls for conveniences
Behaviours
- the hook returns a set of methods for making HTTP calls
- it prepends base URL
- it adds JWT token to headers if a token is found
- it adds default headers
- it accepts custom headers and overrides default headers
- it accepts custom options and passes them to HTTP requests
Code
import { renderHook } from '@testing-library/react-hooks'; | |
import axios from 'axios'; | |
import { localStorageKey, useClient } from './useClient'; | |
const testApiUrl = 'http://localhost:3000/api'; | |
jest.mock('axios'); | |
jest.mock('@epic/environments', () => ({ | |
environment: { | |
apiUrl: testApiUrl, | |
}, | |
})); | |
function setup() { | |
const mockedAxios = axios as jest.Mocked<typeof axios>; | |
mockedAxios.get.mockResolvedValue({ data: 'test' }); | |
const queries = renderHook(() => useClient()); | |
return { ...queries, mockedAxios }; | |
} | |
const token = 'I.Am.A.Token'; | |
localStorage.setItem(localStorageKey, token); | |
afterAll(() => { | |
localStorage.clear(); | |
}); | |
describe('useClient - get', function () { | |
afterEach(() => { | |
jest.clearAllMocks(); | |
}); | |
it('should prepend base api url and attach bear token and default header options', async function () { | |
const { result, mockedAxios } = setup(); | |
await result.current.get('test'); | |
expect(mockedAxios.get).toHaveBeenCalledWith(`${testApiUrl}/test`, { | |
headers: { | |
Authorization: `Bearer ${token}`, | |
Accept: 'application/json', | |
'Content-Type': 'application/json', | |
}, | |
}); | |
}); | |
it('should return body of data when called', async function () { | |
const { result } = setup(); | |
const expected = await result.current.get('test'); | |
expect(expected).toEqual('test'); | |
}); | |
it('should add given custom headers', async function () { | |
const { result, mockedAxios } = setup(); | |
await result.current.get('test', { headers: { 'X-Test': 'test' } }); | |
expect(mockedAxios.get).toHaveBeenCalledWith(`${testApiUrl}/test`, { | |
headers: { | |
Authorization: `Bearer ${token}`, | |
Accept: 'application/json', | |
'Content-Type': 'application/json', | |
'X-Test': 'test', | |
}, | |
}); | |
}); | |
it('should override default header configuration with given custom headers', async function () { | |
const { result, mockedAxios } = setup(); | |
await result.current.get('test', { | |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | |
}); | |
expect(mockedAxios.get).toHaveBeenCalledWith(`${testApiUrl}/test`, { | |
headers: { | |
Authorization: `Bearer ${token}`, | |
Accept: 'application/json', | |
'Content-Type': 'application/x-www-form-urlencoded', | |
}, | |
}); | |
}); | |
it('should add custom configs', async function () { | |
const { result, mockedAxios } = setup(); | |
await result.current.get('test', { | |
params: { | |
ID: 12345, | |
}, | |
}); | |
expect(mockedAxios.get).toHaveBeenCalledWith(`${testApiUrl}/test`, { | |
headers: { | |
Authorization: `Bearer ${token}`, | |
Accept: 'application/json', | |
'Content-Type': 'application/json', | |
}, | |
params: { | |
ID: 12345, | |
}, | |
}); | |
}); | |
it('getRaw should return data when called', async function () { | |
const { result } = setup(); | |
const expected = await result.current.getRaw('test'); | |
expect(expected).toEqual({ data: 'test' }); | |
}); | |
}); |
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; | |
import { useCallback } from 'react'; | |
import { environment } from '@epic/environments'; | |
const apiURL = environment.apiUrl; | |
export const localStorageKey = '__this_is_a_token_key_'; | |
export const getToken: () => string | null = () => { | |
return localStorage.getItem(localStorageKey); | |
}; | |
export const useClient = () => { | |
const assembleOptions = ({ headers, ...options }: AxiosRequestConfig): AxiosRequestConfig => { | |
const token = getToken(); | |
const defaultHeaders = { | |
'Content-Type': 'application/json', | |
Accept: 'application/json', | |
}; | |
if (token) { | |
return { | |
headers: { | |
...defaultHeaders, | |
...headers, | |
Authorization: `Bearer ${token}`, | |
}, | |
...options, | |
}; | |
} | |
return { | |
headers: { | |
...defaultHeaders, | |
...headers, | |
}, | |
...options, | |
}; | |
}; | |
const post = useCallback(async function post<T, R = void>( | |
endpoint: string, | |
data: T, | |
options: AxiosRequestConfig = {} | |
): Promise<R> { | |
return await axios | |
.post<T, { data: R }>(`${apiURL}/${endpoint}`, data, assembleOptions(options)) | |
.then((response) => response.data); | |
}, | |
[]); | |
const getRaw = useCallback(async function get<T>( | |
endpoint: string, | |
options: AxiosRequestConfig = {} | |
): Promise<AxiosResponse<T>> { | |
return await axios.get<T>(`${apiURL}/${endpoint}`, assembleOptions(options)); | |
}, | |
[]); | |
const get = useCallback( | |
async function get<T>( | |
endpoint: string, | |
{ headers, ...customConfig }: AxiosRequestConfig = {} | |
): Promise<T> { | |
return await getRaw<T>(endpoint, { headers, ...customConfig }).then( | |
(response) => response.data | |
); | |
}, | |
[getRaw] | |
); | |
const patch = useCallback(async function patch<T, R>( | |
endpoint: string, | |
data: T, | |
options: AxiosRequestConfig = {} | |
): Promise<R> { | |
return await axios | |
.patch<T, { data: R }>(`${apiURL}/${endpoint}`, data, assembleOptions(options)) | |
.then((response) => response.data); | |
}, | |
[]); | |
const del = useCallback(async function del( | |
endpoint: string, | |
options: AxiosRequestConfig = {} | |
): Promise<AxiosResponse> { | |
return await axios.delete(`${apiURL}/${endpoint}`, assembleOptions(options)); | |
}, | |
[]); | |
return { post, get, patch, del, getRaw }; | |
}; |
Notes
mock
axios
so that tests don't make actual HTTP callsJSDOM implements
localstorage
, and there is no need to mock it.setup
usesrenderHook
from@testing-library/react-hooks
, which wraps the hook in a component and return aresult
object withcurrent
property set to whatever the hook returns.setup
also mock the return ofaxios.get
method and returns the mocked axios object for assertions.included tests only test
get
. Other tests are similar, so they are left out in this blog post for simplicity.
Top comments (0)