2

I have the below two tests

import {put, select, takeEvery} from 'redux-saga/effects';
import {combineReducers} from 'redux';

export default class SessionReducer {
    public static readonly _initialState: any = {
        disconnectCounts: {},
    };

    public static reducer(state: any = SessionReducer._initialState, action: any): any {
        // console.log('reducer', action);
        let newState: any;
        switch (action.type) {
            case 'DEVICE_DISCONNECTED':
                newState = {
                    ...state,
                };
                if (!newState.disconnectCounts[action.value]) newState.disconnectCounts[action.value] = 0;
                newState.disconnectCounts[action.value]++;
                newState.error = {
                    type: 'DEVICE_DISCONNECTED',
                    utc: 1,
                };

                return newState;
            default:
                return state;
        }
    }
}

export function* errorHandler() {
    yield takeEvery(['DEVICE_DISCONNECTED'], function* (action: any) {
        let state = yield select();
        console.log('*********', state);
        if (state.session.disconnectCounts[action.value] > 1) {
            yield put({
                type: 'WATCH_REBOOT_REQUEST',
            });
            // state.session.disconnectCounts[action.value] = 0
        }
    });
}
let action = {type: 'DEVICE_DISCONNECTED', value: '111'};
describe('Handles Error States and Transitions', () => {
    test('Sends watch reboot request when disconnection count threshold met', () => {
        return expectSaga(errorHandler)
            .withReducer(
                combineReducers({
                    session: SessionReducer.reducer,
                }),
                {session: SessionReducer._initialState},
            )
            .dispatch(action)
            .dispatch(action)
            .put({type: 'WATCH_REBOOT_REQUEST'})
            .run()
            .then((result: {storeState: any}) => {
                debugger;

                let session = result.storeState.session;
                expect(session.disconnectCounts[action.value]).toBe(2); // values for error are tested in reducer test
                expect(session.error).toBeTruthy(); // values for error are tested in reducer test
            });
    });
    test('Does not send WATCH_REBOOT_REQUEST when threshold not met', () => {
        return expectSaga(errorHandler)
            .withReducer(
                combineReducers({
                    session: SessionReducer.reducer,
                }),
                {session: SessionReducer._initialState},
            )
            .dispatch(action)
            .run()
            .then((result: {storeState: any}) => {
                let session = result.storeState.session;
                expect(session.disconnectCounts[action.value]).toBe(1); // values for error are tested in reducer test
                // expect(session.currentScreen).toEqual('actionRequiredIdleScreen');
            });
    });
});

If you run each test independently, i used .only, they pass but run them without .only and the second test always fails w/ too many values in disconnectCounts

  Handles Error States and Transitions
    ✓ Sends watch reboot request when disconnection count threshold met (263 ms)
    ✕ Does not send WATCH_REBOOT_REQUEST when threshold not met (258 ms)

  ● Handles Error States and Transitions › Does not send WATCH_REBOOT_REQUEST when threshold not met

    expect(received).toBe(expected) // Object.is equality

    Expected: 1
    Received: 3

      76 |             .then((result: {storeState: any}) => {
      77 |                 let session = result.storeState.session;
    > 78 |                 expect(session.disconnectCounts[action.value]).toBe(1); // values for error are tested in reducer test
         |                                                                ^
      79 |                 // expect(session.currentScreen).toEqual('actionRequiredIdleScreen');
      80 |             });
      81 |     });

      at __tests__/sagas/sagaStateIssue.ts:78:64
      at tryCallOne (node_modules/promise/lib/core.js:37:12)
      at node_modules/promise/lib/core.js:123:15
      at flush (node_modules/asap/raw.js:50:29)

What o what am I missing?

Robel Robel Lingstuyl
  • 1,341
  • 1
  • 11
  • 28
  • 1
    Needs more details, please create a minimal, reproducible example. Show us the saga code. Please simplify the code, remove irrelevant parts, and locate the problem. – Lin Du Oct 13 '21 at 11:33
  • @slideshowp2 thanks, ya I should have taken the time to do so initially. Thanks for looking. – Robel Robel Lingstuyl Oct 13 '21 at 15:39

2 Answers2

2

Putting the reducer and state together in a class is an anti-pattern of redux.

const initialState = () => ({ disconnectCounts: {} });
const reducer = (state: any = initialState(), action: any): any => {

You are holding on to a single reference for initialState It's better to have a function that returns a new instance

https://codesandbox.io/s/proud-morning-0w4wu?file=/src/testy.test.ts:175-182

Here is a sandbox with the tests running

Daniel Duong
  • 1,084
  • 4
  • 11
  • will you please elaborate on your anti-pattern comment? We use the reducer pattern published by Redux Toolkit; I thought that was official? – Robel Robel Lingstuyl Nov 08 '21 at 20:27
  • @RobelRobelLingstuyl The two patterns in Redux toolkit are utilising the two main utils they have. Which are createReducer which takes a callback with a builder function. The other is createSlice which will produce the actionCreators in an object. There isn't any benefit in putting a reducer inside a class, I haven't seen that pattern anywhere but here. – Daniel Duong Nov 09 '21 at 21:48
0

I think the problem is that both tests use the same reference of SessionReducer._initialState. When you pass it to the state in withReducer, it isn't cloned in any way and so you end up working with the same object in memory.

There is lots of way how to fix it, e.g. you can have a method instead of a property to create the initial object:

_initialState = () => ({disconnectCounts: {}})
// ...
.withReducer(
  combineReducers({
    session: SessionReducer.reducer,
  }),
  {session: SessionReducer._initialState()},
)

or you can deeply clone the object yourself in the test

const deepClone = obj => JSON.parse(JSON.stringify(obj))
// ...
.withReducer(
  combineReducers({
    session: SessionReducer.reducer,
  }),
  {session: deepClone(SessionReducer._initialState)},
)
Martin Kadlec
  • 4,702
  • 2
  • 20
  • 33