@reduxjs/toolkit
Version:
The official, opinionated, batteries-included toolset for efficient Redux development
935 lines (836 loc) • 27.1 kB
text/typescript
import { noop } from '@internal/listenerMiddleware/utils'
import type { PayloadAction, WithSlice } from '@reduxjs/toolkit'
import {
asyncThunkCreator,
buildCreateSlice,
combineSlices,
configureStore,
createAction,
createSlice,
} from '@reduxjs/toolkit'
type CreateSlice = typeof createSlice
describe('createSlice', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(noop)
beforeEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
vi.restoreAllMocks()
})
describe('when slice is undefined', () => {
it('should throw an error', () => {
expect(() =>
// @ts-ignore
createSlice({
reducers: {
increment: (state) => state + 1,
multiply: (state, action: PayloadAction<number>) =>
state * action.payload,
},
initialState: 0,
}),
).toThrowError()
})
})
describe('when slice is an empty string', () => {
it('should throw an error', () => {
expect(() =>
createSlice({
name: '',
reducers: {
increment: (state) => state + 1,
multiply: (state, action: PayloadAction<number>) =>
state * action.payload,
},
initialState: 0,
}),
).toThrowError()
})
})
describe('when initial state is undefined', () => {
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'development')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('should throw an error', () => {
createSlice({
name: 'test',
reducers: {},
initialState: undefined,
})
expect(consoleErrorSpy).toHaveBeenCalledOnce()
expect(consoleErrorSpy).toHaveBeenLastCalledWith(
'You must provide an `initialState` value that is not `undefined`. You may have misspelled `initialState`',
)
})
})
describe('when passing slice', () => {
const { actions, reducer, caseReducers } = createSlice({
reducers: {
increment: (state) => state + 1,
},
initialState: 0,
name: 'cool',
})
it('should create increment action', () => {
expect(actions.hasOwnProperty('increment')).toBe(true)
})
it('should have the correct action for increment', () => {
expect(actions.increment()).toEqual({
type: 'cool/increment',
payload: undefined,
})
})
it('should return the correct value from reducer', () => {
expect(reducer(undefined, actions.increment())).toEqual(1)
})
it('should include the generated case reducers', () => {
expect(caseReducers).toBeTruthy()
expect(caseReducers.increment).toBeTruthy()
expect(typeof caseReducers.increment).toBe('function')
})
it('getInitialState should return the state', () => {
const initialState = 42
const slice = createSlice({
name: 'counter',
initialState,
reducers: {},
})
expect(slice.getInitialState()).toBe(initialState)
})
it('should allow non-draftable initial state', () => {
expect(() =>
createSlice({
name: 'params',
initialState: new URLSearchParams(),
reducers: {},
}),
).not.toThrowError()
})
})
describe('when initialState is a function', () => {
const initialState = () => ({ user: '' })
const { actions, reducer } = createSlice({
reducers: {
setUserName: (state, action) => {
state.user = action.payload
},
},
initialState,
name: 'user',
})
it('should set the username', () => {
expect(reducer(undefined, actions.setUserName('eric'))).toEqual({
user: 'eric',
})
})
it('getInitialState should return the state', () => {
const initialState = () => 42
const slice = createSlice({
name: 'counter',
initialState,
reducers: {},
})
expect(slice.getInitialState()).toBe(42)
})
it('should allow non-draftable initial state', () => {
expect(() =>
createSlice({
name: 'params',
initialState: () => new URLSearchParams(),
reducers: {},
}),
).not.toThrowError()
})
})
describe('when mutating state object', () => {
const initialState = { user: '' }
const { actions, reducer } = createSlice({
reducers: {
setUserName: (state, action) => {
state.user = action.payload
},
},
initialState,
name: 'user',
})
it('should set the username', () => {
expect(reducer(initialState, actions.setUserName('eric'))).toEqual({
user: 'eric',
})
})
})
describe('when passing extra reducers', () => {
const addMore = createAction<{ amount: number }>('ADD_MORE')
const { reducer } = createSlice({
name: 'test',
reducers: {
increment: (state) => state + 1,
multiply: (state, action) => state * action.payload,
},
extraReducers: (builder) => {
builder.addCase(
addMore,
(state, action) => state + action.payload.amount,
)
},
initialState: 0,
})
it('should call extra reducers when their actions are dispatched', () => {
const result = reducer(10, addMore({ amount: 5 }))
expect(result).toBe(15)
})
describe('builder callback for extraReducers', () => {
const increment = createAction<number, 'increment'>('increment')
test('can be used with actionCreators', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addCase(
increment,
(state, action) => state + action.payload,
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
test('can be used with string action types', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addCase(
'increment',
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload,
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
test('prevents the same action type from being specified twice', () => {
expect(() => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder
.addCase('increment', (state) => state + 1)
.addCase('increment', (state) => state + 1),
})
slice.reducer(undefined, { type: 'unrelated' })
}).toThrowErrorMatchingInlineSnapshot(
`[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
)
})
test('can be used with addMatcher and type guard functions', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addMatcher(
increment.match,
(state, action: { type: 'increment'; payload: number }) =>
state + action.payload,
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
test('can be used with addDefaultCase', () => {
const slice = createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) =>
builder.addDefaultCase(
(state, action) =>
state + (action as PayloadAction<number>).payload,
),
})
expect(slice.reducer(0, increment(5))).toBe(5)
})
// for further tests, see the test of createReducer that goes way more into depth on this
})
})
describe('behavior with enhanced case reducers', () => {
it('should pass all arguments to the prepare function', () => {
const prepare = vi.fn((payload, somethingElse) => ({ payload }))
const testSlice = createSlice({
name: 'test',
initialState: 0,
reducers: {
testReducer: {
reducer: (s) => s,
prepare,
},
},
})
expect(testSlice.actions.testReducer('a', 1)).toEqual({
type: 'test/testReducer',
payload: 'a',
})
expect(prepare).toHaveBeenCalledWith('a', 1)
})
it('should call the reducer function', () => {
const reducer = vi.fn(() => 5)
const testSlice = createSlice({
name: 'test',
initialState: 0,
reducers: {
testReducer: {
reducer,
prepare: (payload: any) => ({ payload }),
},
},
})
testSlice.reducer(0, testSlice.actions.testReducer('testPayload'))
expect(reducer).toHaveBeenCalledWith(
0,
expect.objectContaining({ payload: 'testPayload' }),
)
})
})
describe('circularity', () => {
test('extraReducers can reference each other circularly', () => {
const first = createSlice({
name: 'first',
initialState: 'firstInitial',
reducers: {
something() {
return 'firstSomething'
},
},
extraReducers(builder) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
builder.addCase(second.actions.other, () => {
return 'firstOther'
})
},
})
const second = createSlice({
name: 'second',
initialState: 'secondInitial',
reducers: {
other() {
return 'secondOther'
},
},
extraReducers(builder) {
builder.addCase(first.actions.something, () => {
return 'secondSomething'
})
},
})
expect(first.reducer(undefined, { type: 'unrelated' })).toBe(
'firstInitial',
)
expect(first.reducer(undefined, first.actions.something())).toBe(
'firstSomething',
)
expect(first.reducer(undefined, second.actions.other())).toBe(
'firstOther',
)
expect(second.reducer(undefined, { type: 'unrelated' })).toBe(
'secondInitial',
)
expect(second.reducer(undefined, first.actions.something())).toBe(
'secondSomething',
)
expect(second.reducer(undefined, second.actions.other())).toBe(
'secondOther',
)
})
})
describe('Deprecation warnings', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllEnvs()
})
// NOTE: This needs to be in front of the later `createReducer` call to check the one-time warning
it('Throws an error if the legacy object notation is used', async () => {
const { createSlice } = await import('../createSlice')
let dummySlice = (createSlice as CreateSlice)({
name: 'dummy',
initialState: [],
reducers: {},
extraReducers: {
// @ts-ignore
a: () => [],
},
})
let reducer: any
// Have to trigger the lazy creation
const wrapper = () => {
reducer = dummySlice.reducer
reducer(undefined, { type: 'dummy' })
}
expect(wrapper).toThrowError(
/The object notation for `createSlice.extraReducers` has been removed/,
)
dummySlice = (createSlice as CreateSlice)({
name: 'dummy',
initialState: [],
reducers: {},
extraReducers: {
// @ts-ignore
a: () => [],
},
})
expect(wrapper).toThrowError(
/The object notation for `createSlice.extraReducers` has been removed/,
)
})
// TODO Determine final production behavior here
it.todo('Crashes in production', () => {
vi.stubEnv('NODE_ENV', 'production')
const { createSlice } = require('../createSlice')
const dummySlice = (createSlice as CreateSlice)({
name: 'dummy',
initialState: [],
reducers: {},
// @ts-ignore
extraReducers: {},
})
const wrapper = () => {
const { reducer } = dummySlice
reducer(undefined, { type: 'dummy' })
}
expect(wrapper).toThrowError(
/The object notation for `createSlice.extraReducers` has been removed/,
)
vi.unstubAllEnvs()
})
})
describe('slice selectors', () => {
const slice = createSlice({
name: 'counter',
initialState: 42,
reducers: {},
selectors: {
selectSlice: (state) => state,
selectMultiple: Object.assign(
(state: number, multiplier: number) => state * multiplier,
{ test: 0 },
),
},
})
it('expects reducer under slice.reducerPath if no selectState callback passed', () => {
const testState = {
[slice.reducerPath]: slice.getInitialState(),
}
const { selectSlice, selectMultiple } = slice.selectors
expect(selectSlice(testState)).toBe(slice.getInitialState())
expect(selectMultiple(testState, 2)).toBe(slice.getInitialState() * 2)
})
it('allows passing a selector for a custom location', () => {
const customState = {
number: slice.getInitialState(),
}
const { selectSlice, selectMultiple } = slice.getSelectors(
(state: typeof customState) => state.number,
)
expect(selectSlice(customState)).toBe(slice.getInitialState())
expect(selectMultiple(customState, 2)).toBe(slice.getInitialState() * 2)
})
it('allows accessing properties on the selector', () => {
expect(slice.selectors.selectMultiple.unwrapped.test).toBe(0)
})
it('has selectSlice attached to slice, which can go without this', () => {
const slice = createSlice({
name: 'counter',
initialState: 42,
reducers: {},
})
const { selectSlice } = slice
expect(() => selectSlice({ counter: 42 })).not.toThrow()
expect(selectSlice({ counter: 42 })).toBe(42)
})
})
describe('slice injections', () => {
it('uses injectInto to inject slice into combined reducer', () => {
const slice = createSlice({
name: 'counter',
initialState: 42,
reducers: {
increment: (state) => ++state,
},
selectors: {
selectMultiple: (state, multiplier: number) => state * multiplier,
},
})
const { increment } = slice.actions
const combinedReducer = combineSlices({
static: slice.reducer,
}).withLazyLoadedSlices<WithSlice<typeof slice>>()
const uninjectedState = combinedReducer(undefined, increment())
expect(uninjectedState.counter).toBe(undefined)
const injectedSlice = slice.injectInto(combinedReducer)
// selector returns initial state if undefined in real state
expect(injectedSlice.selectSlice(uninjectedState)).toBe(
slice.getInitialState(),
)
expect(injectedSlice.selectors.selectMultiple({}, 1)).toBe(
slice.getInitialState(),
)
expect(injectedSlice.getSelectors().selectMultiple(undefined, 1)).toBe(
slice.getInitialState(),
)
const injectedState = combinedReducer(undefined, increment())
expect(injectedSlice.selectSlice(injectedState)).toBe(
slice.getInitialState() + 1,
)
expect(injectedSlice.selectors.selectMultiple(injectedState, 1)).toBe(
slice.getInitialState() + 1,
)
})
it('allows providing a custom name to inject under', () => {
const slice = createSlice({
name: 'counter',
reducerPath: 'injected',
initialState: 42,
reducers: {
increment: (state) => ++state,
},
selectors: {
selectMultiple: (state, multiplier: number) => state * multiplier,
},
})
const { increment } = slice.actions
const combinedReducer = combineSlices({
static: slice.reducer,
}).withLazyLoadedSlices<WithSlice<typeof slice> & { injected2: number }>()
const uninjectedState = combinedReducer(undefined, increment())
expect(uninjectedState.injected).toBe(undefined)
const injected = slice.injectInto(combinedReducer)
const injectedState = combinedReducer(undefined, increment())
expect(injected.selectSlice(injectedState)).toBe(
slice.getInitialState() + 1,
)
expect(injected.selectors.selectMultiple(injectedState, 2)).toBe(
(slice.getInitialState() + 1) * 2,
)
const injected2 = slice.injectInto(combinedReducer, {
reducerPath: 'injected2',
})
const injected2State = combinedReducer(undefined, increment())
expect(injected2.selectSlice(injected2State)).toBe(
slice.getInitialState() + 1,
)
expect(injected2.selectors.selectMultiple(injected2State, 2)).toBe(
(slice.getInitialState() + 1) * 2,
)
})
it('avoids incorrectly caching selectors', () => {
const slice = createSlice({
name: 'counter',
reducerPath: 'injected',
initialState: 42,
reducers: {
increment: (state) => ++state,
},
selectors: {
selectMultiple: (state, multiplier: number) => state * multiplier,
},
})
expect(slice.getSelectors()).toBe(slice.getSelectors())
const combinedReducer = combineSlices({
static: slice.reducer,
}).withLazyLoadedSlices<WithSlice<typeof slice>>()
const injected = slice.injectInto(combinedReducer)
expect(injected.getSelectors()).not.toBe(slice.getSelectors())
expect(injected.getSelectors().selectMultiple(undefined, 1)).toBe(42)
expect(() =>
// @ts-expect-error
slice.getSelectors().selectMultiple(undefined, 1),
).toThrowErrorMatchingInlineSnapshot(
`[Error: selectState returned undefined for an uninjected slice reducer]`,
)
const injected2 = slice.injectInto(combinedReducer, {
reducerPath: 'other',
})
// can use same cache for localised selectors
expect(injected.getSelectors()).toBe(injected2.getSelectors())
// these should be different
expect(injected.selectors).not.toBe(injected2.selectors)
})
it('caches initial states for selectors', () => {
const slice = createSlice({
name: 'counter',
initialState: () => ({ value: 0 }),
reducers: {},
selectors: {
selectObj: (state) => state,
},
})
// not cached
expect(slice.getInitialState()).not.toBe(slice.getInitialState())
expect(slice.reducer(undefined, { type: 'dummy' })).not.toBe(
slice.reducer(undefined, { type: 'dummy' }),
)
const combinedReducer = combineSlices({
static: slice.reducer,
}).withLazyLoadedSlices<WithSlice<typeof slice>>()
const injected = slice.injectInto(combinedReducer)
// still not cached
expect(injected.getInitialState()).not.toBe(injected.getInitialState())
expect(injected.reducer(undefined, { type: 'dummy' })).not.toBe(
injected.reducer(undefined, { type: 'dummy' }),
)
// cached
expect(injected.selectSlice({})).toBe(injected.selectSlice({}))
expect(injected.selectors.selectObj({})).toBe(
injected.selectors.selectObj({}),
)
})
})
describe('reducers definition with asyncThunks', () => {
it('is disabled by default', () => {
expect(() =>
createSlice({
name: 'test',
initialState: [] as any[],
reducers: (create) => ({ thunk: create.asyncThunk(() => {}) }),
}),
).toThrowErrorMatchingInlineSnapshot(
`[Error: Cannot use \`create.asyncThunk\` in the built-in \`createSlice\`. Use \`buildCreateSlice({ creators: { asyncThunk: asyncThunkCreator } })\` to create a customised version of \`createSlice\`.]`,
)
})
const createAppSlice = buildCreateSlice({
creators: { asyncThunk: asyncThunkCreator },
})
function pending(state: any[], action: any) {
state.push(['pendingReducer', action])
}
function fulfilled(state: any[], action: any) {
state.push(['fulfilledReducer', action])
}
function rejected(state: any[], action: any) {
state.push(['rejectedReducer', action])
}
function settled(state: any[], action: any) {
state.push(['settledReducer', action])
}
test('successful thunk', async () => {
const slice = createAppSlice({
name: 'test',
initialState: [] as any[],
reducers: (create) => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg: string, api) {
return Promise.resolve('resolved payload')
},
{ pending, fulfilled, rejected, settled },
),
}),
})
const store = configureStore({
reducer: slice.reducer,
})
await store.dispatch(slice.actions.thunkReducers('test'))
expect(store.getState()).toMatchObject([
[
'pendingReducer',
{
type: 'test/thunkReducers/pending',
payload: undefined,
},
],
[
'fulfilledReducer',
{
type: 'test/thunkReducers/fulfilled',
payload: 'resolved payload',
},
],
[
'settledReducer',
{
type: 'test/thunkReducers/fulfilled',
payload: 'resolved payload',
},
],
])
})
test('rejected thunk', async () => {
const slice = createAppSlice({
name: 'test',
initialState: [] as any[],
reducers: (create) => ({
thunkReducers: create.asyncThunk(
// payloadCreator isn't allowed to return never
function payloadCreator(arg: string, api): any {
throw new Error('')
},
{ pending, fulfilled, rejected, settled },
),
}),
})
const store = configureStore({
reducer: slice.reducer,
})
await store.dispatch(slice.actions.thunkReducers('test'))
expect(store.getState()).toMatchObject([
[
'pendingReducer',
{
type: 'test/thunkReducers/pending',
payload: undefined,
},
],
[
'rejectedReducer',
{
type: 'test/thunkReducers/rejected',
payload: undefined,
},
],
[
'settledReducer',
{
type: 'test/thunkReducers/rejected',
payload: undefined,
},
],
])
})
test('with options', async () => {
const slice = createAppSlice({
name: 'test',
initialState: [] as any[],
reducers: (create) => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg: string, api) {
return 'should not call this'
},
{
options: {
condition() {
return false
},
dispatchConditionRejection: true,
},
pending,
fulfilled,
rejected,
settled,
},
),
}),
})
const store = configureStore({
reducer: slice.reducer,
})
await store.dispatch(slice.actions.thunkReducers('test'))
expect(store.getState()).toMatchObject([
[
'rejectedReducer',
{
type: 'test/thunkReducers/rejected',
payload: undefined,
meta: { condition: true },
},
],
[
'settledReducer',
{
type: 'test/thunkReducers/rejected',
payload: undefined,
meta: { condition: true },
},
],
])
})
test('has caseReducers for the asyncThunk', async () => {
const slice = createAppSlice({
name: 'test',
initialState: [],
reducers: (create) => ({
thunkReducers: create.asyncThunk(
function payloadCreator(arg, api) {
return Promise.resolve('resolved payload')
},
{ pending, fulfilled, settled },
),
}),
})
expect(slice.caseReducers.thunkReducers.pending).toBe(pending)
expect(slice.caseReducers.thunkReducers.fulfilled).toBe(fulfilled)
expect(slice.caseReducers.thunkReducers.settled).toBe(settled)
// even though it is not defined above, this should at least be a no-op function to match the TypeScript typings
// and should be callable as a reducer even if it does nothing
expect(() =>
slice.caseReducers.thunkReducers.rejected(
[],
slice.actions.thunkReducers.rejected(
new Error('test'),
'fakeRequestId',
),
),
).not.toThrow()
})
test('can define reducer with prepare statement using create.preparedReducer', async () => {
const slice = createSlice({
name: 'test',
initialState: [] as any[],
reducers: (create) => ({
prepared: create.preparedReducer(
(p: string, m: number, e: { message: string }) => ({
payload: p,
meta: m,
error: e,
}),
(state, action) => {
state.push(action)
},
),
}),
})
expect(
slice.reducer(
[],
slice.actions.prepared('test', 1, { message: 'err' }),
),
).toMatchInlineSnapshot(`
[
{
"error": {
"message": "err",
},
"meta": 1,
"payload": "test",
"type": "test/prepared",
},
]
`)
})
test('throws an error when invoked with a normal `prepare` object that has not gone through a `create.preparedReducer` call', async () => {
expect(() =>
createSlice({
name: 'test',
initialState: [] as any[],
reducers: (create) => ({
prepared: {
prepare: (p: string, m: number, e: { message: string }) => ({
payload: p,
meta: m,
error: e,
}),
reducer: (state, action) => {
state.push(action)
},
},
}),
}),
).toThrowErrorMatchingInlineSnapshot(
`[Error: Please use the \`create.preparedReducer\` notation for prepared action creators with the \`create\` notation.]`,
)
})
})
})