4

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!

a.barbieri
  • 2,398
  • 3
  • 30
  • 58
hairbo
  • 3,113
  • 2
  • 27
  • 34

1 Answers1

6

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'])
})
Vlemert
  • 316
  • 2
  • 5
  • I tried your suggestion (thanks!), and it gets me closer, but now on the line `.next({type: actions.LOGOUT}).cancel(createMockTask())`, I get this: Assertion 4 failed: cancel effects do not match, but the "expected" and "actual" values match. I'll post the contents of actual vs. expected in the next comment. – hairbo Dec 05 '16 at 20:59
  • Expected: { '@@redux-saga/TASK': true, isRunning: [Function: isRunning],result: [Function: result], error: [Function: error], setRunning: [Function: setRunning], setResult: [Function: setResult], setError: [Function: setError] } Actual: { '@@redux-saga/TASK': true, isRunning: [Function: isRunning],result: [Function: result], error: [Function: error], setRunning: [Function: setRunning], setResult: [Function: setResult], setError: [Function: setError] } – hairbo Dec 05 '16 at 21:02
  • Ah yeah I made a mistake, see the answer for the updated code. – Vlemert Dec 06 '16 at 10:47
  • Ah! I think one thing I didn't realize was that when you call `createMockTask()`, the instance has a unique signature, so I need to do something like `const mockTask1 = createMockTask()`, and then refer to it multiple times, as you do in your example. I'm slowly starting to understand this. Thanks again. – hairbo Dec 06 '16 at 17:12
  • Yeah that's exactly what went wrong. Anyway, happy to help! – Vlemert Dec 07 '16 at 19:05