UNPKG

12.7 kBPlain TextView Raw
1import { Store, MiddlewareAPI, Dispatch } from 'redux'
2import {
3 createImmutableStateInvariantMiddleware,
4 isImmutableDefault,
5 trackForMutations,
6 ImmutableStateInvariantMiddlewareOptions
7} from './immutableStateInvariantMiddleware'
8import { mockConsole, createConsole, getLog } from 'console-testing-library'
9
10describe('createImmutableStateInvariantMiddleware', () => {
11 let state: { foo: { bar: number[]; baz: string } }
12 const getState: Store['getState'] = () => state
13
14 function middleware(options: ImmutableStateInvariantMiddlewareOptions = {}) {
15 return createImmutableStateInvariantMiddleware(options)({
16 getState
17 } as MiddlewareAPI)
18 }
19
20 beforeEach(() => {
21 state = { foo: { bar: [2, 3, 4], baz: 'baz' } }
22 })
23
24 it('sends the action through the middleware chain', () => {
25 const next: Dispatch = action => ({ ...action, returned: true })
26 const dispatch = middleware()(next)
27
28 expect(dispatch({ type: 'SOME_ACTION' })).toEqual({
29 type: 'SOME_ACTION',
30 returned: true
31 })
32 })
33
34 it('throws if mutating inside the dispatch', () => {
35 const next: Dispatch = action => {
36 state.foo.bar.push(5)
37 return action
38 }
39
40 const dispatch = middleware()(next)
41
42 expect(() => {
43 dispatch({ type: 'SOME_ACTION' })
44 }).toThrow(new RegExp('foo\\.bar\\.3'))
45 })
46
47 it('throws if mutating between dispatches', () => {
48 const next: Dispatch = action => action
49
50 const dispatch = middleware()(next)
51
52 dispatch({ type: 'SOME_ACTION' })
53 state.foo.bar.push(5)
54 expect(() => {
55 dispatch({ type: 'SOME_OTHER_ACTION' })
56 }).toThrow(new RegExp('foo\\.bar\\.3'))
57 })
58
59 it('does not throw if not mutating inside the dispatch', () => {
60 const next: Dispatch = action => {
61 state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
62 return action
63 }
64
65 const dispatch = middleware()(next)
66
67 expect(() => {
68 dispatch({ type: 'SOME_ACTION' })
69 }).not.toThrow()
70 })
71
72 it('does not throw if not mutating between dispatches', () => {
73 const next: Dispatch = action => action
74
75 const dispatch = middleware()(next)
76
77 dispatch({ type: 'SOME_ACTION' })
78 state = { ...state, foo: { ...state.foo, baz: 'changed!' } }
79 expect(() => {
80 dispatch({ type: 'SOME_OTHER_ACTION' })
81 }).not.toThrow()
82 })
83
84 it('works correctly with circular references', () => {
85 const next: Dispatch = action => action
86
87 const dispatch = middleware()(next)
88
89 let x: any = {}
90 let y: any = {}
91 x.y = y
92 y.x = x
93
94 expect(() => {
95 dispatch({ type: 'SOME_ACTION', x })
96 }).not.toThrow()
97 })
98
99 it('respects "isImmutable" option', function() {
100 const isImmutable = (value: any) => true
101 const next: Dispatch = action => {
102 state.foo.bar.push(5)
103 return action
104 }
105
106 const dispatch = middleware({ isImmutable })(next)
107
108 expect(() => {
109 dispatch({ type: 'SOME_ACTION' })
110 }).not.toThrow()
111 })
112
113 it('respects "ignoredPaths" option', () => {
114 const next: Dispatch = action => {
115 state.foo.bar.push(5)
116 return action
117 }
118
119 const dispatch = middleware({ ignoredPaths: ['foo.bar'] })(next)
120
121 expect(() => {
122 dispatch({ type: 'SOME_ACTION' })
123 }).not.toThrow()
124 })
125
126 it('alias "ignore" to "ignoredPath" and respects option', () => {
127 const next: Dispatch = action => {
128 state.foo.bar.push(5)
129 return action
130 }
131
132 const dispatch = middleware({ ignore: ['foo.bar'] })(next)
133
134 expect(() => {
135 dispatch({ type: 'SOME_ACTION' })
136 }).not.toThrow()
137 })
138
139 it('Should print a warning if execution takes too long', () => {
140 state.foo.bar = new Array(10000).fill({ value: 'more' })
141
142 const next: Dispatch = action => action
143
144 const dispatch = middleware({ warnAfter: 4 })(next)
145
146 const restore = mockConsole(createConsole())
147 try {
148 dispatch({ type: 'SOME_ACTION' })
149 expect(getLog().log).toMatch(
150 /^ImmutableStateInvariantMiddleware took \d*ms, which is more than the warning threshold of 4ms./
151 )
152 } finally {
153 restore()
154 }
155 })
156
157 it('Should not print a warning if "next" takes too long', () => {
158 const next: Dispatch = action => {
159 const started = Date.now()
160 while (Date.now() - started < 8) {}
161 return action
162 }
163
164 const dispatch = middleware({ warnAfter: 4 })(next)
165
166 const restore = mockConsole(createConsole())
167 try {
168 dispatch({ type: 'SOME_ACTION' })
169 expect(getLog().log).toEqual('')
170 } finally {
171 restore()
172 }
173 })
174})
175
176describe('trackForMutations', () => {
177 function testCasesForMutation(spec: any) {
178 it('returns true and the mutated path', () => {
179 const state = spec.getState()
180 const options = spec.middlewareOptions || {}
181 const { isImmutable = isImmutableDefault, ignoredPaths } = options
182 const tracker = trackForMutations(isImmutable, ignoredPaths, state)
183 const newState = spec.fn(state)
184
185 expect(tracker.detectMutations()).toEqual({
186 wasMutated: true,
187 path: spec.path.join('.')
188 })
189 })
190 }
191
192 function testCasesForNonMutation(spec: any) {
193 it('returns false', () => {
194 const state = spec.getState()
195 const options = spec.middlewareOptions || {}
196 const { isImmutable = isImmutableDefault, ignoredPaths } = options
197 const tracker = trackForMutations(isImmutable, ignoredPaths, state)
198 const newState = spec.fn(state)
199
200 expect(tracker.detectMutations()).toEqual({ wasMutated: false })
201 })
202 }
203
204 interface TestConfig {
205 getState: Store['getState']
206 fn: (s: any) => typeof s | object
207 middlewareOptions?: ImmutableStateInvariantMiddlewareOptions
208 path?: string[]
209 }
210
211 const mutations: Record<string, TestConfig> = {
212 'adding to nested array': {
213 getState: () => ({
214 foo: {
215 bar: [2, 3, 4],
216 baz: 'baz'
217 },
218 stuff: []
219 }),
220 fn: s => {
221 s.foo.bar.push(5)
222 return s
223 },
224 path: ['foo', 'bar', '3']
225 },
226 'adding to nested array and setting new root object': {
227 getState: () => ({
228 foo: {
229 bar: [2, 3, 4],
230 baz: 'baz'
231 },
232 stuff: []
233 }),
234 fn: s => {
235 s.foo.bar.push(5)
236 return { ...s }
237 },
238 path: ['foo', 'bar', '3']
239 },
240 'changing nested string': {
241 getState: () => ({
242 foo: {
243 bar: [2, 3, 4],
244 baz: 'baz'
245 },
246 stuff: []
247 }),
248 fn: s => {
249 s.foo.baz = 'changed!'
250 return s
251 },
252 path: ['foo', 'baz']
253 },
254 'removing nested state': {
255 getState: () => ({
256 foo: {
257 bar: [2, 3, 4],
258 baz: 'baz'
259 },
260 stuff: []
261 }),
262 fn: s => {
263 delete s.foo
264 return s
265 },
266 path: ['foo']
267 },
268 'adding to array': {
269 getState: () => ({
270 foo: {
271 bar: [2, 3, 4],
272 baz: 'baz'
273 },
274 stuff: []
275 }),
276 fn: s => {
277 s.stuff.push(1)
278 return s
279 },
280 path: ['stuff', '0']
281 },
282 'adding object to array': {
283 getState: () => ({
284 stuff: []
285 }),
286 fn: s => {
287 s.stuff.push({ foo: 1, bar: 2 })
288 return s
289 },
290 path: ['stuff', '0']
291 },
292 'mutating previous state and returning new state': {
293 getState: () => ({ counter: 0 }),
294 fn: s => {
295 s.mutation = true
296 return { ...s, counter: s.counter + 1 }
297 },
298 path: ['mutation']
299 },
300 'mutating previous state with non immutable type and returning new state': {
301 getState: () => ({ counter: 0 }),
302 fn: s => {
303 s.mutation = [1, 2, 3]
304 return { ...s, counter: s.counter + 1 }
305 },
306 path: ['mutation']
307 },
308 'mutating previous state with non immutable type and returning new state without that property': {
309 getState: () => ({ counter: 0 }),
310 fn: s => {
311 s.mutation = [1, 2, 3]
312 return { counter: s.counter + 1 }
313 },
314 path: ['mutation']
315 },
316 'mutating previous state with non immutable type and returning new simple state': {
317 getState: () => ({ counter: 0 }),
318 fn: s => {
319 s.mutation = [1, 2, 3]
320 return 1
321 },
322 path: ['mutation']
323 },
324 'mutating previous state by deleting property and returning new state without that property': {
325 getState: () => ({ counter: 0, toBeDeleted: true }),
326 fn: s => {
327 delete s.toBeDeleted
328 return { counter: s.counter + 1 }
329 },
330 path: ['toBeDeleted']
331 },
332 'mutating previous state by deleting nested property': {
333 getState: () => ({ nested: { counter: 0, toBeDeleted: true }, foo: 1 }),
334 fn: s => {
335 delete s.nested.toBeDeleted
336 return { nested: { counter: s.counter + 1 } }
337 },
338 path: ['nested', 'toBeDeleted']
339 },
340 'update reference': {
341 getState: () => ({ foo: {} }),
342 fn: s => {
343 s.foo = {}
344 return s
345 },
346 path: ['foo']
347 },
348 'cannot ignore root state': {
349 getState: () => ({ foo: {} }),
350 fn: s => {
351 s.foo = {}
352 return s
353 },
354 middlewareOptions: {
355 ignoredPaths: ['']
356 },
357 path: ['foo']
358 },
359 'catching state mutation in non-ignored branch': {
360 getState: () => ({
361 foo: {
362 bar: [1, 2]
363 },
364 boo: {
365 yah: [1, 2]
366 }
367 }),
368 fn: s => {
369 s.foo.bar.push(3)
370 s.boo.yah.push(3)
371 return s
372 },
373 middlewareOptions: {
374 ignoredPaths: ['foo']
375 },
376 path: ['boo', 'yah', '2']
377 }
378 }
379
380 Object.keys(mutations).forEach(mutationDesc => {
381 describe(mutationDesc, () => {
382 testCasesForMutation(mutations[mutationDesc])
383 })
384 })
385
386 const nonMutations: Record<string, TestConfig> = {
387 'not doing anything': {
388 getState: () => ({ a: 1, b: 2 }),
389 fn: s => s
390 },
391 'from undefined to something': {
392 getState: () => undefined,
393 fn: s => ({ foo: 'bar' })
394 },
395 'returning same state': {
396 getState: () => ({
397 foo: {
398 bar: [2, 3, 4],
399 baz: 'baz'
400 },
401 stuff: []
402 }),
403 fn: s => s
404 },
405 'returning a new state object with nested new string': {
406 getState: () => ({
407 foo: {
408 bar: [2, 3, 4],
409 baz: 'baz'
410 },
411 stuff: []
412 }),
413 fn: s => {
414 return { ...s, foo: { ...s.foo, baz: 'changed!' } }
415 }
416 },
417 'returning a new state object with nested new array': {
418 getState: () => ({
419 foo: {
420 bar: [2, 3, 4],
421 baz: 'baz'
422 },
423 stuff: []
424 }),
425 fn: s => {
426 return { ...s, foo: { ...s.foo, bar: [...s.foo.bar, 5] } }
427 }
428 },
429 'removing nested state': {
430 getState: () => ({
431 foo: {
432 bar: [2, 3, 4],
433 baz: 'baz'
434 },
435 stuff: []
436 }),
437 fn: s => {
438 return { ...s, foo: {} }
439 }
440 },
441 'having a NaN in the state': {
442 getState: () => ({ a: NaN, b: Number.NaN }),
443 fn: s => s
444 },
445 'ignoring branches from mutation detection': {
446 getState: () => ({
447 foo: {
448 bar: 'bar'
449 }
450 }),
451 fn: s => {
452 s.foo.bar = 'baz'
453 return s
454 },
455 middlewareOptions: {
456 ignoredPaths: ['foo']
457 }
458 },
459 'ignoring nested branches from mutation detection': {
460 getState: () => ({
461 foo: {
462 bar: [1, 2],
463 boo: {
464 yah: [1, 2]
465 }
466 }
467 }),
468 fn: s => {
469 s.foo.bar.push(3)
470 s.foo.boo.yah.push(3)
471 return s
472 },
473 middlewareOptions: {
474 ignoredPaths: ['foo.bar', 'foo.boo.yah']
475 }
476 },
477 'ignoring nested array indices from mutation detection': {
478 getState: () => ({
479 stuff: [{ a: 1 }, { a: 2 }]
480 }),
481 fn: s => {
482 s.stuff[1].a = 3
483 return s
484 },
485 middlewareOptions: {
486 ignoredPaths: ['stuff.1']
487 }
488 }
489 }
490
491 Object.keys(nonMutations).forEach(nonMutationDesc => {
492 describe(nonMutationDesc, () => {
493 testCasesForNonMutation(nonMutations[nonMutationDesc])
494 })
495 })
496})