UNPKG

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