Search code examples
unit-testingreduxjestjsredux-sagaredux-saga-test-plan

Cannot figure out how to test redux-saga function with redux-saga-test-plan


I'm teaching myself how to use redux-saga, while at the same time teaching myself unit testing, specifically Jest. I took a sample saga from redux-saga's documentation, here:

http://yelouafi.github.io/redux-saga/docs/advanced/NonBlockingCalls.html

...and modified it for my own purposes. It's supposed to be a simple authentication handler, which listens either for a login or logout action (since the function doesn't know whether the user is logged in or not), and then takes appropriate action. I've tested the function within the app, and it appears to function as expected, which is great. Here's the function:

function* authFlow() { 
    while (true) {
        const initialAction = yield take (['LOGIN', 'LOGOUT']);
        if (initialAction.type === 'LOGIN') {
            const { username, password } = initialAction.payload;
            const authTask = yield fork(
                authorizeWithRemoteServer, 
                { username: username, password: password }
            );
            const action = yield take(['LOGOUT', 'LOGIN_FAIL']);
            if (action.type === 'LOGOUT') {
                yield cancel(authTask);
                yield call (unauthorizeWithRemoteServer)
            }
        } else {
            yield call (unauthorizeWithRemoteServer)
        }
    }
}

It seems reasonably straightforward, but I'm having a hard time testing it. What follows is an annotated version of my Jest-based test script:

it ('authFlow() should work with successful login and then successful logout', () => {
  const mockCredentials = { 
      username: 'User', 
      password: 'goodpassword' 
  };
  testSaga( stateAuth.watcher )
    // This should test the first 'yield', which is 
    // waiting for LOGIN or LOGOUT. It works
    .next()
    .take(['LOGIN', 'LOGOUT'])

    // This should test 'authorizeWithRemoteServer', 
    // and appears to do that properly
    .next({ 
        type: 'LOGIN', 
        payload: mockCredentials 
    })
    .fork( 
        stateAuth.authorizeWithRemoteServer, 
        mockCredentials)

    // This should reflect 'yield take' after the 'yield fork', 
    // and does so
    .next()
    .take(['LOGOUT', 'LOGIN_FAIL'])

    /* 
       This is where I don't understand what's happening. 
       What I would think I should do is something like this, 
       if I want to test the logout path:
       .next({ type: 'LOGOUT' })
       .cancel(createMockTask())

       ...but that results in the following, perhaps predictable, error:

       cancel(task): argument task is undefined

       What I found does make the test not fail is the following line, but 
       I do not understand why it works. The fact that it matches 
       "take(['LOGIN', 'LOGOUT'])" indicates that it has 
       looped back to the top of the generator
    */
    .next(createMockTask())
    .take(['LOGIN', 'LOGOUT'])
})

So either I'm doing sagas wrong, or I don't understand how to test sagas, or testing this kind of saga is really hard and perhaps impractical.

So what's going on here? Thanks in advance!


Solution

  • Don't know if the answer is still relevant to you, but just in case anyone else stumbles upon this in the future:

    In the line

    .next().take(['LOGOUT', 'LOGIN_FAIL'])
    

    you're basically passing undefined, which means that the yield on this line:

    const action = yield take(['LOGOUT', 'LOGIN_FAIL']);
    

    causes action to be undefined.

    What you should be doing is pass the mock task on that line:

    .next(createMockTask()).take(['LOGOUT', 'LOGIN_FAIL'])
    

    I think this would then be the correct test

    it ('authFlow() should work with successful login and then successful logout', () => {
      const mockCredentials = {username: 'User', password: 'goodpassword'};
      testSaga( stateAuth.watcher )
        //this should test the first 'yield', which is waiting for LOGIN or LOGOUT. It works
        .next().take(['LOGIN', 'LOGOUT'])
    
        // This should test 'authorizeWithRemoteServer', and appears to do that properly
        .next({type: 'LOGIN', payload: mockCredentials}).fork( stateAuth.authorizeWithRemoteServer, mockCredentials)
    
        // We pass a mock task here
        .next(createMockTask()).take(['LOGOUT', 'LOGIN_FAIL'])
    
        // And then this should be correct
        .next({type: 'LOGOUT'}).cancel(createMockTask())
    
        // after which the saga loops back
        .take(['LOGIN', 'LOGOUT'])
    })
    

    Remember that when calling next(), you are fulfilling the previous yield.

    Update: whoops, the result of createMockTask() should be stored to be able to use it for an assert. This should be the correct code:

    it ('authFlow() should work with successful login and then successful logout', () => {
      const mockCredentials = {username: 'User', password: 'goodpassword'};
      const mockTask = createMockTask();
      testSaga( stateAuth.watcher )
        //this should test the first 'yield', which is waiting for LOGIN or LOGOUT. It works
        .next().take(['LOGIN', 'LOGOUT'])
    
        // This should test 'authorizeWithRemoteServer', and appears to do that properly
        .next({type: 'LOGIN', payload: mockCredentials}).fork( stateAuth.authorizeWithRemoteServer, mockCredentials)
    
        // We pass a mock task here
        .next(mockTask).take(['LOGOUT', 'LOGIN_FAIL'])
    
        // And then this should be correct
        .next({type: 'LOGOUT'}).cancel(mockTask)
    
        // after which the saga loops back
        .take(['LOGIN', 'LOGOUT'])
    })