source: node_modules/@reduxjs/toolkit/src/tests/createReducer.test.ts@ a762898

Last change on this file since a762898 was a762898, checked in by istevanoska <ilinastevanoska@…>, 5 months ago

Added visualizations

  • Property mode set to 100644
File size: 20.5 KB
Line 
1import type {
2 CaseReducer,
3 Draft,
4 PayloadAction,
5 Reducer,
6 UnknownAction,
7} from '@reduxjs/toolkit'
8import {
9 createAction,
10 createAsyncThunk,
11 createNextState,
12 createReducer,
13 isPlainObject,
14} from '@reduxjs/toolkit'
15import { waitMs } from './utils/helpers'
16
17interface Todo {
18 text: string
19 completed?: boolean
20}
21
22interface AddTodoPayload {
23 newTodo: Todo
24}
25
26interface ToggleTodoPayload {
27 index: number
28}
29
30type TodoState = Todo[]
31type TodosReducer = Reducer<TodoState, PayloadAction<any>>
32type AddTodoReducer = CaseReducer<
33 TodoState,
34 PayloadAction<AddTodoPayload, 'ADD_TODO'>
35>
36
37type ToggleTodoReducer = CaseReducer<
38 TodoState,
39 PayloadAction<ToggleTodoPayload, 'TOGGLE_TODO'>
40>
41
42type CreateReducer = typeof createReducer
43
44const addTodoThunk = createAsyncThunk('todos/add', (todo: Todo) => todo)
45
46describe('createReducer', () => {
47 describe('given impure reducers with immer', () => {
48 const addTodo: AddTodoReducer = (state, action) => {
49 const { newTodo } = action.payload
50
51 // Can safely call state.push() here
52 state.push({ ...newTodo, completed: false })
53 }
54
55 const toggleTodo: ToggleTodoReducer = (state, action) => {
56 const { index } = action.payload
57
58 const todo = state[index]
59 // Can directly modify the todo object
60 todo.completed = !todo.completed
61 }
62
63 const todosReducer = createReducer([] as TodoState, (builder) => {
64 builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
65 })
66
67 behavesLikeReducer(todosReducer)
68 })
69
70 describe('Deprecation warnings', () => {
71 beforeEach(() => {
72 vi.resetModules()
73 })
74
75 afterEach(() => {
76 vi.unstubAllEnvs()
77 })
78
79 it('Throws an error if the legacy object notation is used', async () => {
80 const { createReducer } = await import('../createReducer')
81 const wrapper = () => {
82 const dummyReducer = (createReducer as CreateReducer)(
83 [] as TodoState,
84 // @ts-ignore
85 {},
86 )
87 }
88
89 expect(wrapper).toThrowError(
90 /The object notation for `createReducer` has been removed/,
91 )
92
93 expect(wrapper).toThrowError(
94 /The object notation for `createReducer` has been removed/,
95 )
96 })
97
98 it('Crashes in production', async () => {
99 vi.stubEnv('NODE_ENV', 'production')
100 const { createReducer } = await import('../createReducer')
101 const wrapper = () => {
102 const dummyReducer = (createReducer as CreateReducer)(
103 [] as TodoState,
104 // @ts-ignore
105 {},
106 )
107 }
108
109 expect(wrapper).toThrowError()
110 })
111 })
112
113 describe('Immer in a production environment', () => {
114 beforeEach(() => {
115 vi.resetModules()
116 vi.stubEnv('NODE_ENV', 'production')
117 })
118
119 afterEach(() => {
120 vi.unstubAllEnvs()
121 })
122
123 test('Freezes data in production', async () => {
124 const { createReducer } = await import('../createReducer')
125 const addTodo: AddTodoReducer = (state, action) => {
126 const { newTodo } = action.payload
127 state.push({ ...newTodo, completed: false })
128 }
129
130 const toggleTodo: ToggleTodoReducer = (state, action) => {
131 const { index } = action.payload
132 const todo = state[index]
133 todo.completed = !todo.completed
134 }
135
136 const todosReducer = createReducer([] as TodoState, (builder) => {
137 builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
138 })
139
140 const result = todosReducer([], {
141 type: 'ADD_TODO',
142 payload: { text: 'Buy milk' },
143 })
144
145 const mutateStateOutsideReducer = () => (result[0].text = 'edited')
146 expect(mutateStateOutsideReducer).toThrowError(
147 'Cannot add property text, object is not extensible',
148 )
149 })
150
151 test('Freezes initial state', () => {
152 const initialState = [{ text: 'Buy milk' }]
153 const todosReducer = createReducer(initialState, () => {})
154 const frozenInitialState = todosReducer(undefined, { type: 'dummy' })
155
156 const mutateStateOutsideReducer = () =>
157 (frozenInitialState[0].text = 'edited')
158 expect(mutateStateOutsideReducer).toThrowError(
159 /Cannot assign to read only property/,
160 )
161 })
162 test('does not throw error if initial state is not draftable', () => {
163 expect(() =>
164 createReducer(new URLSearchParams(), () => {}),
165 ).not.toThrowError()
166 })
167 })
168
169 describe('given pure reducers with immutable updates', () => {
170 const addTodo: AddTodoReducer = (state, action) => {
171 const { newTodo } = action.payload
172
173 // Updates the state immutably without relying on immer
174 return state.concat({ ...newTodo, completed: false })
175 }
176
177 const toggleTodo: ToggleTodoReducer = (state, action) => {
178 const { index } = action.payload
179
180 // Updates the todo object immutably withot relying on immer
181 return state.map((todo, i) => {
182 if (i !== index) return todo
183 return { ...todo, completed: !todo.completed }
184 })
185 }
186
187 const todosReducer = createReducer([] as TodoState, (builder) => {
188 builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
189 })
190
191 behavesLikeReducer(todosReducer)
192 })
193
194 describe('Accepts a lazy state init function to generate initial state', () => {
195 const addTodo: AddTodoReducer = (state, action) => {
196 const { newTodo } = action.payload
197 state.push({ ...newTodo, completed: false })
198 }
199
200 const toggleTodo: ToggleTodoReducer = (state, action) => {
201 const { index } = action.payload
202 const todo = state[index]
203 todo.completed = !todo.completed
204 }
205
206 const lazyStateInit = () => [] as TodoState
207
208 const todosReducer = createReducer([] as TodoState, (builder) => {
209 builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
210 })
211
212 behavesLikeReducer(todosReducer)
213
214 it('Should only call the init function when `undefined` state is passed in', () => {
215 const spy = vi.fn().mockReturnValue(42)
216
217 const dummyReducer = createReducer(spy, () => {})
218 expect(spy).not.toHaveBeenCalled()
219
220 dummyReducer(123, { type: 'dummy' })
221 expect(spy).not.toHaveBeenCalled()
222
223 const initialState = dummyReducer(undefined, { type: 'dummy' })
224 expect(spy).toHaveBeenCalledOnce()
225 })
226 })
227
228 describe('given draft state from immer', () => {
229 const addTodo: AddTodoReducer = (state, action) => {
230 const { newTodo } = action.payload
231
232 // Can safely call state.push() here
233 state.push({ ...newTodo, completed: false })
234 }
235
236 const toggleTodo: ToggleTodoReducer = (state, action) => {
237 const { index } = action.payload
238
239 const todo = state[index]
240 // Can directly modify the todo object
241 todo.completed = !todo.completed
242 }
243
244 const todosReducer = createReducer([] as TodoState, (builder) => {
245 builder.addCase('ADD_TODO', addTodo).addCase('TOGGLE_TODO', toggleTodo)
246 })
247
248 const wrappedReducer: TodosReducer = (state = [], action) => {
249 return createNextState(state, (draft: Draft<TodoState>) => {
250 todosReducer(draft, action)
251 })
252 }
253
254 behavesLikeReducer(wrappedReducer)
255 })
256
257 describe('builder callback for actionMap', () => {
258 const increment = createAction<number, 'increment'>('increment')
259 const decrement = createAction<number, 'decrement'>('decrement')
260
261 test('can be used with ActionCreators', () => {
262 const reducer = createReducer(0, (builder) =>
263 builder
264 .addCase(increment, (state, action) => state + action.payload)
265 .addCase(decrement, (state, action) => state - action.payload),
266 )
267 expect(reducer(0, increment(5))).toBe(5)
268 expect(reducer(5, decrement(5))).toBe(0)
269 })
270 test('can be used with string types', () => {
271 const reducer = createReducer(0, (builder) =>
272 builder
273 .addCase(
274 'increment',
275 (state, action: { type: 'increment'; payload: number }) =>
276 state + action.payload,
277 )
278 .addCase(
279 'decrement',
280 (state, action: { type: 'decrement'; payload: number }) =>
281 state - action.payload,
282 ),
283 )
284 expect(reducer(0, increment(5))).toBe(5)
285 expect(reducer(5, decrement(5))).toBe(0)
286 })
287 test('can be used with ActionCreators and string types combined', () => {
288 const reducer = createReducer(0, (builder) =>
289 builder
290 .addCase(increment, (state, action) => state + action.payload)
291 .addCase(
292 'decrement',
293 (state, action: { type: 'decrement'; payload: number }) =>
294 state - action.payload,
295 ),
296 )
297 expect(reducer(0, increment(5))).toBe(5)
298 expect(reducer(5, decrement(5))).toBe(0)
299 })
300 test('will throw an error when returning undefined from a non-draftable state', () => {
301 const reducer = createReducer(0, (builder) =>
302 builder.addCase(
303 'decrement',
304 (state, action: { type: 'decrement'; payload: number }) => {},
305 ),
306 )
307 expect(() => reducer(5, decrement(5))).toThrowErrorMatchingInlineSnapshot(
308 `[Error: A case reducer on a non-draftable value must not return undefined]`,
309 )
310 })
311 test('allows you to return undefined if the state was null, thus skipping an update', () => {
312 const reducer = createReducer(null as number | null, (builder) =>
313 builder.addCase(
314 'decrement',
315 (state, action: { type: 'decrement'; payload: number }) => {
316 if (typeof state === 'number') {
317 return state - action.payload
318 }
319 return undefined
320 },
321 ),
322 )
323 expect(reducer(0, decrement(5))).toBe(-5)
324 expect(reducer(null, decrement(5))).toBe(null)
325 })
326 test('allows you to return null', () => {
327 const reducer = createReducer(0 as number | null, (builder) =>
328 builder.addCase(
329 'decrement',
330 (state, action: { type: 'decrement'; payload: number }) => {
331 return null
332 },
333 ),
334 )
335 expect(reducer(5, decrement(5))).toBe(null)
336 })
337 test('allows you to return 0', () => {
338 const reducer = createReducer(0, (builder) =>
339 builder.addCase(
340 'decrement',
341 (state, action: { type: 'decrement'; payload: number }) =>
342 state - action.payload,
343 ),
344 )
345 expect(reducer(5, decrement(5))).toBe(0)
346 })
347 test('will throw if the same type is used twice', () => {
348 expect(() => {
349 createReducer(0, (builder) => {
350 builder
351 .addCase(increment, (state, action) => state + action.payload)
352 .addCase(increment, (state, action) => state + action.payload)
353 .addCase(decrement, (state, action) => state - action.payload)
354 })
355 }).toThrowErrorMatchingInlineSnapshot(
356 `[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
357 )
358 expect(() => {
359 createReducer(0, (builder) => {
360 builder
361 .addCase(increment, (state, action) => state + action.payload)
362 .addCase('increment', (state) => state + 1)
363 .addCase(decrement, (state, action) => state - action.payload)
364 })
365 }).toThrowErrorMatchingInlineSnapshot(
366 `[Error: \`builder.addCase\` cannot be called with two reducers for the same action type 'increment']`,
367 )
368 })
369
370 test('will throw if an empty type is used', () => {
371 const customActionCreator = (payload: number) => ({
372 type: 'custom_action',
373 payload,
374 })
375 customActionCreator.type = ''
376 expect(() => {
377 createReducer(0, (builder) => {
378 builder.addCase(
379 customActionCreator,
380 (state, action) => state + action.payload,
381 )
382 })
383 }).toThrowErrorMatchingInlineSnapshot(
384 `[Error: \`builder.addCase\` cannot be called with an empty action type]`,
385 )
386 })
387 })
388
389 describe('builder "addMatcher" method', () => {
390 const prepareNumberAction = (payload: number) => ({
391 payload,
392 meta: { type: 'number_action' },
393 })
394 const prepareStringAction = (payload: string) => ({
395 payload,
396 meta: { type: 'string_action' },
397 })
398
399 const numberActionMatcher = (
400 a: UnknownAction,
401 ): a is PayloadAction<number> =>
402 isPlainObject(a.meta) &&
403 'type' in a.meta &&
404 (a.meta as Record<'type', unknown>).type === 'number_action'
405
406 const stringActionMatcher = (
407 a: UnknownAction,
408 ): a is PayloadAction<string> =>
409 isPlainObject(a.meta) &&
410 'type' in a.meta &&
411 (a.meta as Record<'type', unknown>).type === 'string_action'
412
413 const incrementBy = createAction('increment', prepareNumberAction)
414 const decrementBy = createAction('decrement', prepareNumberAction)
415 const concatWith = createAction('concat', prepareStringAction)
416
417 const initialState = { numberActions: 0, stringActions: 0 }
418
419 test('uses the reducer of matching actionMatchers', () => {
420 const reducer = createReducer(initialState, (builder) =>
421 builder
422 .addMatcher(numberActionMatcher, (state) => {
423 state.numberActions += 1
424 })
425 .addMatcher(stringActionMatcher, (state) => {
426 state.stringActions += 1
427 }),
428 )
429 expect(reducer(undefined, incrementBy(1))).toEqual({
430 numberActions: 1,
431 stringActions: 0,
432 })
433 expect(reducer(undefined, decrementBy(1))).toEqual({
434 numberActions: 1,
435 stringActions: 0,
436 })
437 expect(reducer(undefined, concatWith('foo'))).toEqual({
438 numberActions: 0,
439 stringActions: 1,
440 })
441 })
442 test('falls back to defaultCase', () => {
443 const reducer = createReducer(initialState, (builder) =>
444 builder
445 .addCase(concatWith, (state) => {
446 state.stringActions += 1
447 })
448 .addMatcher(numberActionMatcher, (state) => {
449 state.numberActions += 1
450 })
451 .addDefaultCase((state) => {
452 state.numberActions = -1
453 state.stringActions = -1
454 }),
455 )
456 expect(reducer(undefined, { type: 'somethingElse' })).toEqual({
457 numberActions: -1,
458 stringActions: -1,
459 })
460 })
461 test('runs reducer cases followed by all matching actionMatchers', () => {
462 const reducer = createReducer(initialState, (builder) =>
463 builder
464 .addCase(incrementBy, (state) => {
465 state.numberActions = state.numberActions * 10 + 1
466 })
467 .addMatcher(numberActionMatcher, (state) => {
468 state.numberActions = state.numberActions * 10 + 2
469 })
470 .addMatcher(stringActionMatcher, (state) => {
471 state.stringActions = state.stringActions * 10 + 1
472 })
473 .addMatcher(numberActionMatcher, (state) => {
474 state.numberActions = state.numberActions * 10 + 3
475 }),
476 )
477 expect(reducer(undefined, incrementBy(1))).toEqual({
478 numberActions: 123,
479 stringActions: 0,
480 })
481 expect(reducer(undefined, decrementBy(1))).toEqual({
482 numberActions: 23,
483 stringActions: 0,
484 })
485 expect(reducer(undefined, concatWith('foo'))).toEqual({
486 numberActions: 0,
487 stringActions: 1,
488 })
489 })
490 test('works with `actionCreator.match`', () => {
491 const reducer = createReducer(initialState, (builder) =>
492 builder.addMatcher(incrementBy.match, (state) => {
493 state.numberActions += 100
494 }),
495 )
496 expect(reducer(undefined, incrementBy(1))).toEqual({
497 numberActions: 100,
498 stringActions: 0,
499 })
500 })
501 test('calling addCase, addMatcher and addDefaultCase in a nonsensical order should result in an error in development mode', () => {
502 expect(() =>
503 createReducer(initialState, (builder: any) =>
504 builder
505 .addMatcher(numberActionMatcher, () => {})
506 .addCase(incrementBy, () => {}),
507 ),
508 ).toThrowErrorMatchingInlineSnapshot(
509 `[Error: \`builder.addCase\` should only be called before calling \`builder.addMatcher\`]`,
510 )
511 expect(() =>
512 createReducer(initialState, (builder: any) =>
513 builder.addDefaultCase(() => {}).addCase(incrementBy, () => {}),
514 ),
515 ).toThrowErrorMatchingInlineSnapshot(
516 `[Error: \`builder.addCase\` should only be called before calling \`builder.addDefaultCase\`]`,
517 )
518 expect(() =>
519 createReducer(initialState, (builder: any) =>
520 builder
521 .addDefaultCase(() => {})
522 .addMatcher(numberActionMatcher, () => {}),
523 ),
524 ).toThrowErrorMatchingInlineSnapshot(
525 `[Error: \`builder.addMatcher\` should only be called before calling \`builder.addDefaultCase\`]`,
526 )
527 expect(() =>
528 createReducer(initialState, (builder: any) =>
529 builder.addDefaultCase(() => {}).addDefaultCase(() => {}),
530 ),
531 ).toThrowErrorMatchingInlineSnapshot(
532 `[Error: \`builder.addDefaultCase\` can only be called once]`,
533 )
534 })
535 })
536 describe('builder "addAsyncThunk" method', () => {
537 const initialState = { todos: [] as Todo[], loading: false, errored: false }
538 test('uses the matching reducer for each action type', () => {
539 const reducer = createReducer(initialState, (builder) =>
540 builder.addAsyncThunk(addTodoThunk, {
541 pending(state) {
542 state.loading = true
543 },
544 fulfilled(state, action) {
545 state.todos.push(action.payload)
546 },
547 rejected(state) {
548 state.errored = true
549 },
550 settled(state) {
551 state.loading = false
552 },
553 }),
554 )
555 const todo: Todo = { text: 'test' }
556 expect(reducer(undefined, addTodoThunk.pending('test', todo))).toEqual({
557 todos: [],
558 loading: true,
559 errored: false,
560 })
561 expect(
562 reducer(undefined, addTodoThunk.fulfilled(todo, 'test', todo)),
563 ).toEqual({
564 todos: [todo],
565 loading: false,
566 errored: false,
567 })
568 expect(
569 reducer(undefined, addTodoThunk.rejected(new Error(), 'test', todo)),
570 ).toEqual({
571 todos: [],
572 loading: false,
573 errored: true,
574 })
575 })
576 test('calling addAsyncThunk after addDefaultCase should result in an error in development mode', () => {
577 expect(() =>
578 createReducer(initialState, (builder: any) =>
579 builder.addDefaultCase(() => {}).addAsyncThunk(addTodoThunk, {}),
580 ),
581 ).toThrowErrorMatchingInlineSnapshot(
582 `[Error: \`builder.addAsyncThunk\` should only be called before calling \`builder.addDefaultCase\`]`,
583 )
584 })
585 })
586})
587
588function behavesLikeReducer(todosReducer: TodosReducer) {
589 it('should handle initial state', () => {
590 const initialAction = { type: '', payload: undefined }
591 expect(todosReducer(undefined, initialAction)).toEqual([])
592 })
593
594 it('should handle ADD_TODO', () => {
595 expect(
596 todosReducer([], {
597 type: 'ADD_TODO',
598 payload: { newTodo: { text: 'Run the tests' } },
599 }),
600 ).toEqual([
601 {
602 text: 'Run the tests',
603 completed: false,
604 },
605 ])
606
607 expect(
608 todosReducer(
609 [
610 {
611 text: 'Run the tests',
612 completed: false,
613 },
614 ],
615 {
616 type: 'ADD_TODO',
617 payload: { newTodo: { text: 'Use Redux' } },
618 },
619 ),
620 ).toEqual([
621 {
622 text: 'Run the tests',
623 completed: false,
624 },
625 {
626 text: 'Use Redux',
627 completed: false,
628 },
629 ])
630
631 expect(
632 todosReducer(
633 [
634 {
635 text: 'Run the tests',
636 completed: false,
637 },
638 {
639 text: 'Use Redux',
640 completed: false,
641 },
642 ],
643 {
644 type: 'ADD_TODO',
645 payload: { newTodo: { text: 'Fix the tests' } },
646 },
647 ),
648 ).toEqual([
649 {
650 text: 'Run the tests',
651 completed: false,
652 },
653 {
654 text: 'Use Redux',
655 completed: false,
656 },
657 {
658 text: 'Fix the tests',
659 completed: false,
660 },
661 ])
662 })
663
664 it('should handle TOGGLE_TODO', () => {
665 expect(
666 todosReducer(
667 [
668 {
669 text: 'Run the tests',
670 completed: false,
671 },
672 {
673 text: 'Use Redux',
674 completed: false,
675 },
676 ],
677 {
678 type: 'TOGGLE_TODO',
679 payload: { index: 0 },
680 },
681 ),
682 ).toEqual([
683 {
684 text: 'Run the tests',
685 completed: true,
686 },
687 {
688 text: 'Use Redux',
689 completed: false,
690 },
691 ])
692 })
693}
Note: See TracBrowser for help on using the repository browser.