import React from 'react' import axios from 'axios' import { render, fireEvent } from '@testing-library/react' import { renderHook, act } from '@testing-library/react-hooks' import defaultUseAxios, { configure as defaultConfigure, resetConfigure as defaultResetConfigure, clearCache as defaultClearCache, loadCache as defaultLoadCache, serializeCache as defaultSerializeCache, makeUseAxios } from '../src' import { mockCancelToken } from './testUtils' jest.mock('axios') let cancel let token let errors beforeEach(() => { ;({ cancel, token } = mockCancelToken(axios)) }) beforeAll(() => { const error = console.error console.error = (...args) => { error.apply(console, args) // keep default behaviour errors.push(args) } }) beforeEach(() => { errors = [] }) afterEach(() => { // assert that no errors were logged during tests expect(errors).toEqual([]) }) describe('default useAxios', () => { standardTests( defaultUseAxios, defaultConfigure, defaultResetConfigure, defaultClearCache, defaultLoadCache, defaultSerializeCache ) }) describe('makeUseAxios', () => { it('should be a function', () => { expect(makeUseAxios).toBeInstanceOf(Function) }) it('should not throw', () => { expect(makeUseAxios()).toBeTruthy() }) it('should provide a custom implementation of axios', () => { const mockAxios = jest.fn().mockResolvedValueOnce({ data: 'whatever' }) const setup = makeSetup(makeUseAxios({ axios: mockAxios })) const { waitForNextUpdate } = setup('') expect(mockAxios).toHaveBeenCalled() return waitForNextUpdate() }) describe('globally disabled cache', () => { let setup beforeEach(() => { setup = makeSetup(makeUseAxios({ cache: false })) }) it('should use local state across rerenders', async () => { axios.mockResolvedValueOnce({ data: 'whatever' }) const { waitForNextUpdate, rerender } = setup('') await waitForNextUpdate() rerender() expect(axios).toHaveBeenCalledTimes(1) }) it('should hit network across component mounts', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, unmount } = setup('') await waitForNextUpdate() unmount() await setup('').waitForNextUpdate() expect(axios).toHaveBeenCalledTimes(2) }) }) describe('default hook options', () => { describe('manual', () => { it('should override default manual option', () => { const setup = makeSetup( makeUseAxios({ defaultOptions: { manual: true } }) ) setup('') expect(axios).not.toHaveBeenCalled() }) }) describe('useCache', () => { it('should override default useCache option', async () => { const setup = makeSetup( makeUseAxios({ defaultOptions: { useCache: false } }) ) axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, unmount } = setup('') await waitForNextUpdate() unmount() await setup('').waitForNextUpdate() expect(axios).toHaveBeenCalledTimes(2) }) }) describe('ssr', () => { it('should be able to set ssr option', () => { makeSetup(makeUseAxios({ defaultOptions: { ssr: false } })) }) }) }) describe('standard tests', () => { const useAxios = makeUseAxios() standardTests( useAxios, useAxios.configure, useAxios.resetConfigure, useAxios.clearCache, useAxios.loadCache, useAxios.serializeCache ) describe('with custom configuration', () => { const useAxios = makeUseAxios({ axios }) standardTests( useAxios, useAxios.configure, useAxios.resetConfigure, useAxios.clearCache, useAxios.loadCache, useAxios.serializeCache ) }) }) }) function makeSetup(useAxios) { return (config, options = undefined) => renderHook( ({ config, options }) => { return useAxios(config, options) }, { initialProps: { config, options } } ) } function standardTests( useAxios, configure, resetConfigure, clearCache, loadCache, serializeCache ) { const setup = makeSetup(useAxios) beforeEach(clearCache) describe('basic functionality', () => { it('should set loading to true and error to null before the request resolves', async () => { axios.mockResolvedValueOnce({ data: 'whatever' }) const { result, waitForNextUpdate } = setup('') expect(result.current[0].loading).toBe(true) expect(result.current[0].error).toBe(null) await waitForNextUpdate() }) it('should set loading to false when request resolves', async () => { axios.mockResolvedValueOnce({ data: 'whatever' }) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() expect(result.current[0].loading).toBe(false) expect(result.current[0].error).toBe(null) expect(result.current[0].data).toBe('whatever') }) it('should set the response', async () => { const response = { data: 'whatever' } axios.mockResolvedValueOnce(response) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() expect(result.current[0].loading).toBe(false) expect(result.current[0].error).toBe(null) expect(result.current[0].response).toBe(response) }) it('should set error when request fails', async () => { const error = new Error('boom') axios.mockRejectedValueOnce(error) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() expect(result.current[0].error).toBe(error) }) it('should not reset error when component rerenders', async () => { const error = new Error('boom') axios.mockRejectedValueOnce(error) const { result, waitForNextUpdate, rerender } = setup('') await waitForNextUpdate() expect(result.current[0].error).toBe(error) axios.mockResolvedValueOnce({ data: 'whatever' }) rerender() expect(result.current[0].error).toBe(error) }) it('should reset error when component remounts', async () => { const error = new Error('boom') axios.mockRejectedValueOnce(error) const firstRender = setup('') await firstRender.waitForNextUpdate() expect(firstRender.result.current[0].error).toBe(error) axios.mockResolvedValueOnce({ data: 'whatever' }) const secondRender = setup('') await secondRender.waitForNextUpdate() expect(secondRender.result.current[0].error).toBe(null) }) it('should reset error when refetch succeeds', async () => { const error = new Error('boom') axios.mockRejectedValueOnce(error) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() expect(result.current[0].error).toBe(error) axios.mockResolvedValueOnce({ data: 'whatever' }) // Refetch act(() => { result.current[1]() }) await waitForNextUpdate() expect(result.current[0].error).toBe(null) }) it('should set loading to false when request completes and returns error', async () => { const error = new Error('boom') axios.mockRejectedValueOnce(error) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() expect(result.current[0].loading).toBe(false) expect(result.current[0].error).toBe(error) }) it('should refetch', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() act(() => { result.current[1]() }) expect(result.current[0].loading).toBe(true) expect(axios).toHaveBeenCalledTimes(2) await waitForNextUpdate() }) it('should return the same reference to the fetch function', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { result, rerender, waitForNextUpdate } = setup('') const firstRefetch = result.current[1] rerender() expect(result.current[1]).toBe(firstRefetch) await waitForNextUpdate() }) it('should return the cached response on a new render', async () => { const response = { data: 'whatever' } axios.mockResolvedValueOnce(response) await setup('').waitForNextUpdate() const { result } = setup('') expect(result.current[0]).toEqual({ loading: false, error: null, data: response.data, response }) }) }) describe('request cancellation', () => { describe('effect-generated requests', () => { it('should provide the cancel token to axios', async () => { axios.mockResolvedValueOnce({ data: 'whatever' }) const { waitForNextUpdate } = setup('') expect(axios).toHaveBeenCalledWith( expect.objectContaining({ cancelToken: token }) ) await waitForNextUpdate() }) it('should cancel the outstanding request when the component unmounts', async () => { axios.mockResolvedValueOnce({ data: 'whatever' }) const { waitForNextUpdate, unmount } = setup('') await waitForNextUpdate() unmount() expect(cancel).toHaveBeenCalled() }) it('should cancel the outstanding request when the cancel method is called', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, result } = setup('') await waitForNextUpdate() result.current[2]() expect(cancel).toHaveBeenCalledTimes(1) expect(result.current[0].loading).toBe(false) }) it('should cancel the outstanding request when the component refetches due to a rerender', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, rerender } = setup('initial config') await waitForNextUpdate() rerender({ config: 'new config', options: {} }) expect(cancel).toHaveBeenCalled() await waitForNextUpdate() }) it('should not cancel the outstanding request when the component rerenders with same string config', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, rerender } = setup('initial config') await waitForNextUpdate() rerender() expect(cancel).not.toHaveBeenCalled() }) it('should not cancel the outstanding request when the component rerenders with same object config', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, rerender } = setup({ some: 'config' }) await waitForNextUpdate() rerender() expect(cancel).not.toHaveBeenCalled() }) it('should not cancel the outstanding request when the component rerenders with equal string config', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, rerender } = setup('initial config', {}) await waitForNextUpdate() rerender({ config: 'initial config', options: {} }) expect(cancel).not.toHaveBeenCalled() }) it('should not cancel the outstanding request when the component rerenders with equal object config', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, rerender } = setup({ some: 'config' }, {}) await waitForNextUpdate() rerender({ config: { some: 'config' }, options: {} }) expect(cancel).not.toHaveBeenCalled() }) it('should cancel the outstanding request when the cancel method is called after the component rerenders with same config', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { waitForNextUpdate, rerender, result } = setup('initial config') await waitForNextUpdate() rerender() result.current[2]() expect(cancel).toHaveBeenCalled() expect(result.current[0].loading).toBe(false) }) it('should not dispatch an error when the request is canceled', async () => { const cancellation = new Error('canceled') axios.mockRejectedValueOnce(cancellation) axios.isCancel = jest .fn() .mockImplementationOnce(err => err === cancellation) const { result, waitForNextUpdate } = setup('') await waitForNextUpdate() expect(result.current[0].error).toBeNull() }) it('should return previous state after cancel', async () => { const response = { data: 'whatever' } const cancellation = new Error('canceled') axios.isCancel = jest .fn() .mockImplementationOnce(err => err === cancellation) axios .mockResolvedValueOnce(response) .mockRejectedValueOnce(cancellation) const { result, waitForNextUpdate, rerender } = setup('', { manual: true }) act(() => { result.current[1]() }) await waitForNextUpdate() rerender({ config: 'test', options: { manual: false } }) result.current[2]() await waitForNextUpdate() expect(axios).toHaveBeenCalledTimes(2) expect(result.current[0].error).toBeNull() expect(result.current[0].loading).toBe(false) expect(result.current[0].response).toBe(response) }) }) describe('manual refetches', () => { it('should provide the cancel token to axios', async () => { const { result, waitForNextUpdate } = setup('', { manual: true }) axios.mockResolvedValueOnce({ data: 'whatever' }) act(() => { result.current[1]() }) expect(axios).toHaveBeenCalledTimes(1) expect(axios).toHaveBeenLastCalledWith( expect.objectContaining({ cancelToken: token }) ) await waitForNextUpdate() }) it('should cancel the outstanding manual refetch when the component unmounts', async () => { const { result, waitForNextUpdate, unmount } = setup('', { manual: true }) axios.mockResolvedValueOnce({ data: 'whatever' }) act(() => { result.current[1]() }) await waitForNextUpdate() unmount() expect(cancel).toHaveBeenCalled() }) it('should cancel the outstanding manual refetch when the component refetches', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { result, waitForNextUpdate, rerender } = setup('') act(() => { result.current[1]() }) await waitForNextUpdate() rerender({ config: 'new config', options: {} }) expect(cancel).toHaveBeenCalled() await waitForNextUpdate() }) it('should cancel the outstanding manual refetch when the cancel method is called', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { result, waitForNextUpdate } = setup('', { manual: true }) act(() => { result.current[1]() }) await waitForNextUpdate() result.current[2]() expect(cancel).toHaveBeenCalledTimes(1) expect(result.current[0].loading).toBe(false) }) it('should cancel manual request when the config options change', async () => { axios.mockResolvedValue({ data: 'whatever' }) const { result, waitForNextUpdate, rerender } = setup('', { manual: true }) act(() => { result.current[1]() }) rerender({ config: 'new url', options: { manual: true } }) await waitForNextUpdate() expect(result.current[0].loading).toBe(false) expect(cancel).toHaveBeenCalledTimes(1) expect(axios).toHaveBeenCalledTimes(1) }) it('should throw an error when the request is canceled', async () => { const cancellation = new Error('canceled') axios.mockRejectedValueOnce(cancellation) axios.isCancel = jest .fn() .mockImplementationOnce(err => err === cancellation) const { result } = renderHook(() => useAxios('', { manual: true })) expect(() => act(result.current[1])).rejects.toBe(cancellation) }) it('should return response from cache in hook results', async () => { const response = { data: 'whatever' } axios.mockResolvedValueOnce(response) // first component renders and stores results in cache await setup('').waitForNextUpdate() const { result } = setup('', { manual: true }) // no results on first render as it's a manual request expect(result.current[0]).toEqual({ loading: false, error: null }) // refetch using cache act(() => { result.current[1]({}, { useCache: true }) }) expect(result.current[0]).toEqual({ loading: false, error: null, response, data: response.data }) }) }) }) describe('refetch', () => { describe('when axios resolves', () => { it('should resolve to the response by default', async () => { const response = { data: 'whatever' } axios.mockResolvedValue(response) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('') act(() => { expect(refetch()).resolves.toEqual(response) }) await waitForNextUpdate() expect(axios).toHaveBeenCalledTimes(2) }) it('should resolve to the response when using cache', async () => { const response = { data: 'whatever' } axios.mockResolvedValue(response) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('') await waitForNextUpdate() act(() => { expect(refetch({}, { useCache: true })).resolves.toEqual(response) }) expect(axios).toHaveBeenCalledTimes(1) }) }) describe('when axios rejects', () => { it('should reject with the error by default', async () => { const error = new Error('boom') axios.mockRejectedValue(error) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('') await waitForNextUpdate() act(() => { expect(refetch()).rejects.toEqual(error) }) await waitForNextUpdate() }) it('should reject with the error and skip cache even when using cache', async () => { const error = new Error('boom') axios.mockRejectedValue(error) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('') await waitForNextUpdate() act(() => { expect(refetch({}, { useCache: true })).rejects.toEqual(error) }) await waitForNextUpdate() }) }) describe('configuration override handling', () => { it('should override url', async () => { const response = { data: 'whatever' } axios.mockResolvedValue(response) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('some url') act(() => { expect(refetch('some other url')).resolves.toEqual(response) }) await waitForNextUpdate() expect(axios).toHaveBeenNthCalledWith( 1, expect.objectContaining({ url: 'some url' }) ) expect(axios).toHaveBeenNthCalledWith( 2, expect.objectContaining({ url: 'some other url' }) ) }) it('should merge with the existing configuration', async () => { const response = { data: 'whatever' } axios.mockResolvedValue(response) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('some url') act(() => { expect(refetch({ params: { some: 'param' } })).resolves.toEqual( response ) }) await waitForNextUpdate() expect(axios).toHaveBeenNthCalledWith( 1, expect.objectContaining({ url: 'some url' }) ) expect(axios).toHaveBeenNthCalledWith( 2, expect.objectContaining({ url: 'some url', params: { some: 'param' } }) ) }) it('should ignore config override if it is an event', async () => { const response = { data: 'whatever' } axios.mockResolvedValue(response) const { result: { current: [, refetch] }, waitForNextUpdate } = setup('some url') const handleClick = jest.fn(e => e.persist()) fireEvent.click( render(