source: node_modules/@reduxjs/toolkit/src/tests/immutableStateInvariantMiddleware.test.ts@ ba17441

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

Added visualizations

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