UNPKG

@reduxjs/toolkit

Version:

The official, opinionated, batteries-included toolset for efficient Redux development

466 lines (375 loc) 14 kB
import { createAsyncThunk, miniSerializeError, unwrapResult } from './createAsyncThunk' import { configureStore } from './configureStore' import { AnyAction } from 'redux' import { mockConsole, createConsole, getLog } from 'console-testing-library/pure' declare global { interface Window { AbortController: AbortController } } describe('createAsyncThunk', () => { it('creates the action types', () => { const thunkActionCreator = createAsyncThunk('testType', async () => 42) expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled') expect(thunkActionCreator.pending.type).toBe('testType/pending') expect(thunkActionCreator.rejected.type).toBe('testType/rejected') }) it('works without passing arguments to the payload creator', async () => { const thunkActionCreator = createAsyncThunk('testType', async () => 42) let timesReducerCalled = 0 const reducer = () => { timesReducerCalled++ } const store = configureStore({ reducer }) // reset from however many times the store called it timesReducerCalled = 0 await store.dispatch(thunkActionCreator()) expect(timesReducerCalled).toBe(2) }) it('accepts arguments and dispatches the actions on resolve', async () => { const dispatch = jest.fn() let passedArg: any const result = 42 const args = 123 let generatedRequestId = '' const thunkActionCreator = createAsyncThunk( 'testType', async (arg: number, { requestId }) => { passedArg = arg generatedRequestId = requestId return result } ) const thunkFunction = thunkActionCreator(args) await thunkFunction(dispatch, () => {}, undefined) expect(passedArg).toBe(args) expect(dispatch).toHaveBeenNthCalledWith( 1, thunkActionCreator.pending(generatedRequestId, args) ) expect(dispatch).toHaveBeenNthCalledWith( 2, thunkActionCreator.fulfilled(result, generatedRequestId, args) ) }) it('accepts arguments and dispatches the actions on reject', async () => { const dispatch = jest.fn() const args = 123 let generatedRequestId = '' const error = new Error('Panic!') const thunkActionCreator = createAsyncThunk( 'testType', async (args: number, { requestId }) => { generatedRequestId = requestId throw error } ) const thunkFunction = thunkActionCreator(args) try { await thunkFunction(dispatch, () => {}, undefined) } catch (e) {} expect(dispatch).toHaveBeenNthCalledWith( 1, thunkActionCreator.pending(generatedRequestId, args) ) expect(dispatch).toHaveBeenCalledTimes(2) // Have to check the bits of the action separately since the error was processed const errorAction = dispatch.mock.calls[1][0] expect(errorAction.error).toEqual(miniSerializeError(error)) expect(errorAction.meta.requestId).toBe(generatedRequestId) expect(errorAction.meta.arg).toBe(args) }) it('dispatches an empty error when throwing a random object without serializedError properties', async () => { const dispatch = jest.fn() const args = 123 let generatedRequestId = '' const errorObject = { wny: 'dothis' } const thunkActionCreator = createAsyncThunk( 'testType', async (args: number, { requestId }) => { generatedRequestId = requestId throw errorObject } ) const thunkFunction = thunkActionCreator(args) try { await thunkFunction(dispatch, () => {}, undefined) } catch (e) {} expect(dispatch).toHaveBeenNthCalledWith( 1, thunkActionCreator.pending(generatedRequestId, args) ) expect(dispatch).toHaveBeenCalledTimes(2) const errorAction = dispatch.mock.calls[1][0] expect(errorAction.error).toEqual({}) expect(errorAction.meta.requestId).toBe(generatedRequestId) expect(errorAction.meta.arg).toBe(args) }) it('dispatches an action with a formatted error when throwing an object with known error keys', async () => { const dispatch = jest.fn() const args = 123 let generatedRequestId = '' const errorObject = { name: 'Custom thrown error', message: 'This is not necessary', code: '400' } const thunkActionCreator = createAsyncThunk( 'testType', async (args: number, { requestId }) => { generatedRequestId = requestId throw errorObject } ) const thunkFunction = thunkActionCreator(args) try { await thunkFunction(dispatch, () => {}, undefined) } catch (e) {} expect(dispatch).toHaveBeenNthCalledWith( 1, thunkActionCreator.pending(generatedRequestId, args) ) expect(dispatch).toHaveBeenCalledTimes(2) // Have to check the bits of the action separately since the error was processed const errorAction = dispatch.mock.calls[1][0] expect(errorAction.error).toEqual(miniSerializeError(errorObject)) expect(Object.keys(errorAction.error)).not.toContain('stack') expect(errorAction.meta.requestId).toBe(generatedRequestId) expect(errorAction.meta.arg).toBe(args) }) it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => { const dispatch = jest.fn() const args = 123 let generatedRequestId = '' const errorPayload = { errorMessage: 'I am a fake server-provided 400 payload with validation details', errors: [ { field_one: 'Must be a string' }, { field_two: 'Must be a number' } ] } const thunkActionCreator = createAsyncThunk( 'testType', async (args: number, { requestId, rejectWithValue }) => { generatedRequestId = requestId return rejectWithValue(errorPayload) } ) const thunkFunction = thunkActionCreator(args) try { await thunkFunction(dispatch, () => {}, undefined) } catch (e) {} expect(dispatch).toHaveBeenNthCalledWith( 1, thunkActionCreator.pending(generatedRequestId, args) ) expect(dispatch).toHaveBeenCalledTimes(2) // Have to check the bits of the action separately since the error was processed const errorAction = dispatch.mock.calls[1][0] expect(errorAction.error.message).toEqual('Rejected') expect(errorAction.payload).toBe(errorPayload) expect(errorAction.meta.arg).toBe(args) }) it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => { const dispatch = jest.fn() const args = 123 let generatedRequestId = '' const error = new Error('Panic!') const errorPayload = { errorMessage: 'I am a fake server-provided 400 payload with validation details', errors: [ { field_one: 'Must be a string' }, { field_two: 'Must be a number' } ] } const thunkActionCreator = createAsyncThunk( 'testType', async (args: number, { requestId, rejectWithValue }) => { generatedRequestId = requestId try { throw error } catch (err) { if (!err.response) { throw err } return rejectWithValue(errorPayload) } } ) const thunkFunction = thunkActionCreator(args) try { await thunkFunction(dispatch, () => {}, undefined) } catch (e) {} expect(dispatch).toHaveBeenNthCalledWith( 1, thunkActionCreator.pending(generatedRequestId, args) ) expect(dispatch).toHaveBeenCalledTimes(2) // Have to check the bits of the action separately since the error was processed const errorAction = dispatch.mock.calls[1][0] expect(errorAction.error).toEqual(miniSerializeError(error)) expect(errorAction.payload).toEqual(undefined) expect(errorAction.meta.requestId).toBe(generatedRequestId) expect(errorAction.meta.arg).toBe(args) }) }) describe('createAsyncThunk with abortController', () => { const asyncThunk = createAsyncThunk('test', function abortablePayloadCreator( _: any, { signal } ) { return new Promise((resolve, reject) => { if (signal.aborted) { reject( new DOMException( 'This should never be reached as it should already be handled.', 'AbortError' ) ) } signal.addEventListener('abort', () => { reject(new DOMException('Was aborted while running', 'AbortError')) }) setTimeout(resolve, 100) }) }) let store = configureStore({ reducer(store: AnyAction[] = []) { return store } }) beforeEach(() => { store = configureStore({ reducer(store: AnyAction[] = [], action) { return [...store, action] } }) }) test('normal usage', async () => { await store.dispatch(asyncThunk({})) expect(store.getState()).toEqual([ expect.any(Object), expect.objectContaining({ type: 'test/pending' }), expect.objectContaining({ type: 'test/fulfilled' }) ]) }) test('abort after dispatch', async () => { const promise = store.dispatch(asyncThunk({})) promise.abort('AbortReason') const result = await promise const expectedAbortedAction = { type: 'test/rejected', error: { message: 'AbortReason', name: 'AbortError' }, meta: { aborted: true } } // abortedAction with reason is dispatched after test/pending is dispatched expect(store.getState()).toMatchObject([ {}, { type: 'test/pending' }, expectedAbortedAction ]) // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator expect(result).toMatchObject(expectedAbortedAction) // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator expect(() => unwrapResult(result)).toThrowError( expect.objectContaining(expectedAbortedAction.error) ) }) test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => { const unawareAsyncThunk = createAsyncThunk('unaware', async () => { await new Promise(resolve => setTimeout(resolve, 100)) return 'finished' }) const promise = store.dispatch(unawareAsyncThunk()) promise.abort('AbortReason') const result = await promise const expectedAbortedAction = { type: 'unaware/rejected', error: { message: 'AbortReason', name: 'AbortError' } } // abortedAction with reason is dispatched after test/pending is dispatched expect(store.getState()).toEqual([ expect.any(Object), expect.objectContaining({ type: 'unaware/pending' }), expect.objectContaining(expectedAbortedAction) ]) // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator expect(result).toMatchObject(expectedAbortedAction) // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator expect(() => unwrapResult(result)).toThrowError( expect.objectContaining(expectedAbortedAction.error) ) }) test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => { let running = false const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => { running = true await new Promise(resolve => setTimeout(resolve, 30000)) running = false }) const promise = store.dispatch(longRunningAsyncThunk()) expect(running).toBeTruthy() promise.abort() const result = await promise expect(running).toBeTruthy() expect(result).toMatchObject({ type: 'longRunning/rejected', error: { message: 'Aborted', name: 'AbortError' }, meta: { aborted: true } }) }) describe('behaviour with missing AbortController', () => { let keepAbortController: typeof window['AbortController'] let freshlyLoadedModule: typeof import('./createAsyncThunk') let restore: () => void let nodeEnv: string beforeEach(() => { keepAbortController = window.AbortController delete window.AbortController jest.resetModules() freshlyLoadedModule = require('./createAsyncThunk') restore = mockConsole(createConsole()) nodeEnv = process.env.NODE_ENV! process.env.NODE_ENV = 'development' }) afterEach(() => { process.env.NODE_ENV = nodeEnv restore() window.AbortController = keepAbortController jest.resetModules() }) test('calling `abort` on an asyncThunk works with a FallbackAbortController if no global abortController is not available', async () => { const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk( 'longRunning', async () => { await new Promise(resolve => setTimeout(resolve, 30000)) } ) store.dispatch(longRunningAsyncThunk()).abort() // should only log once, even if called twice store.dispatch(longRunningAsyncThunk()).abort() expect(getLog().log).toMatchInlineSnapshot(` "This platform does not implement AbortController. If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'." `) }) }) })