redux-saga-test-plan: Testing error throw inside saga

Is there a recommended way to test sagas that throw errors? I’m trying to create a test for this function:

const waitForSocketChannelEvent = function* (socketConnectChannel, delayTimeout = 3000) {
  const {event} = yield race({
    event: take(socketConnectChannel),
    timeout: call(delay, delayTimeout),
  });
  if (event !== undefined) {
    return event;
  } else {
    throw new Error('Socket Event Timeout');
  }
};

The only way I could find to test the function above is using something like this:

    test('If the socket event timeouts it should thrown an error', () => {
      const fakeSocketChannel = channel();
      const delay = 1;
      return expectSaga(waitForSocketChannelEvent, fakeSocketChannel, delay)
        .run()
        .catch(error => {
          expect(error).toEqual(new Error('Socket Event Timeout'));
        });
    });

The problem is that even when the test passes a console.error is throw:

console.error node_modules/redux-saga/lib/internal/utils.js:240
    uncaught at waitForSocketChannelEvent 
     Error: Socket Event Timeout
        at waitForSocketChannelEvent$ (/Users/hidalgofdz/development/yellowme/brandon/brandon-web/src/store/socket/socketSagas.js:48:13)

I don’t know if the problem is the function, the test, or both. Is throwing errors inside sagas a good practice? I would really appreciate any feedback.

About this issue

  • Original URL
  • State: open
  • Created 7 years ago
  • Reactions: 25
  • Comments: 19 (3 by maintainers)

Most upvoted comments

I also came to this problem. In saga.docs/error-handling is this example

// expects a dispatch instruction
assert.deepEqual(
  iterator.throw(error).value,
  put({ type: 'PRODUCTS_REQUEST_FAILED', error }),
  "fetchProducts should yield an Effect put({ type: 'PRODUCTS_REQUEST_FAILED', error })"
)

but if your generator is written like this (again, taken from docs)

function* fetchProducts() {
  try {
    const products = yield call(Api.fetch, '/products')
    yield put({ type: 'PRODUCTS_RECEIVED', products })
  }
  catch(error) {
    yield put({ type: 'PRODUCTS_REQUEST_FAILED', error })
  }
}

then in Jest the iterator.throw(... causes unhandled error.

You must give iterator chance to step into try { } block with iterator.next() which moves execution to the first yield, then you can throw() and it will be caught.

like this

// void next() call just for moving to first yield
iterator.next()
// then the throw() is handled by iterators try { } block
assert.deepEqual(
  iterator.throw(error).value,
  put({ type: 'PRODUCTS_REQUEST_FAILED', error }),
  "fetchProducts should yield an Effect put({ type: 'PRODUCTS_REQUEST_FAILED', error })"
)

I too am trying to test a saga that I expect to throw an error. I can test this as follows:

it('throws an error', done => (
  expectSaga(mySaga)
    .run()
    .catch(e => {
      expect(e).toBeInstanceOf(ErrorResponse);
      done();
    })
));

However I still end up with the error output in my console as described by @hidalgofdz. I’ve tracked this down to the logError function in redux-saga, which uses the optional options.logger function passed in to runSaga:

https://github.com/redux-saga/redux-saga/blob/5f5cb8ab93f68d1e2adfe769e5cce99d7a4bc906/packages/core/src/internal/runSaga.js#L56-L62

Which means that the error logging could be disabled by adding a logger: () => {} to the options passed to runSaga from expectSaga. Any reason why this would be a bad idea? Given that the promise returned by run() will reject on unhandled failure, unexpected errors will still get caught by the test runner/top level code.

@cadichris I ran into the same problem. Here is my current workaround

expectSaga(function*() {
    try {
      yield call(getUser, accessToken);
    } catch (error) {
      expect(error).toEqual(new Error('Access token is not valid anymore'));
    }
  })
    .provide([
      [call(getApiConfig), apiConfig],
      [matchers.call.fn(post), { data: {} }],
    ])
    .run();

In Jest, this works for me, nothing too special, see the function in expect()

function* myGenerator() {
  throw new Error('Error Message');
}
describe('With Jest', () => {
    it('should throw myGenerator', () => {
        const iterator = myGenerator();
        expect(() => iterator.next().value).toThrow('Error Message');
    });
}

you can also use this jest.spyOn(global.console, 'error').mockImplementation(jest.fn()) like this it('should do something', () => { jest.spyOn(global.console, 'error').mockImplementation(jest.fn()) // your test here }) mockImplementation will kind of hijack the thrown error and run it the way you want, i.e. do nothing while testing

Thanks for your answers ! @michaeltintiuc I tried your approach but I still see the Exception in the console. Maybe I missed something…are you able to run the test and have no console output ?

@euphocat That’s clever ! With your approach I don’t see the error in the console anymore. 🎉 I extracted a function, so that it can be reused for other tests. I’m not a big fan of the names I came up with…but I couldn’t do better 😄 Here is the extracted function :

function forExceptionTest(saga, ...sagaArgs) {
  return {
    assertThrow: expectedError => {
      let errorWasThrown = false;

      return function*() {
        try {
          yield call(saga, ...sagaArgs);
        } catch (e) {
          errorWasThrown = true;
          expect(e).toEqual(expectedError);
        } finally {
          if (!errorWasThrown)
            throw "Error was expected, but was not thrown by saga";
        }
      };
    },
  };
};

The test will also fail if error is not thrown at all.

And I used it like so :

 it("reports if user is not authorized", async () => {
    await expectSaga(
      forExceptionTest(doSomethingSaga).assertThrow(
        new Error("You're not authorized to do something")
      )
    )
      .provide([[call(isThisAuthorized), false]])
      .run();
  });

In combination with jest I can do something along the lines:

// saga
export function* demo() {
      throw new Error('Some Error);
}

// test
 it('should throw an error', async () => {
    await expect(
      expectSaga(demo)
        .dispatch({ type: DEMO })
        .silentRun()
    ).rejects.toThrowError('Some Error');
 });

Hi everyone,

Thanks for the work on #211 and #217

However, I don’t understand how does #211 actually helps in suppressing the error message logged in the console.

Here is a sample code showing I’m not able to make the console error go away.

Production code :

export function* doSomethingSaga() {
  const iCanDo = yield call(isThisAuthorized);

  if (!iCanDo) 
    throw new Error("You're not authorized to do something");
 } 

Testing code :

 it("reports if user is not authorized", () => {
    return expectSaga(doSomethingSaga)
      .provide([[call(isThisAuthorized), false]])
      .throws(new Error("You're not authorized to do something"))
      .run();
  });

Please note that the above test passes : it’s GREEN, but it produces the following output in console :

image

As you can see, I still see the Error’s message in the console output.

Exact stack trace

 console.error node_modules/@redux-saga/core/dist/chunk-774a4f38.js:104
      Error: You're not authorized to do something
          at doSomethingSaga (...)
          at doSomethingSaga.next (<anonymous>)
          at getNext ([...]/node_modules/redux-saga-test-plan/lib/expectSaga/sagaWrapper.js:59:37)
          at Object._fsmIterator.(anonymous function) [as NEXT] ([...]/node_modules/redux-saga-test-plan/lib/expectSaga/sagaWrapper.js:102:16)
          at Object.next ([...]/node_modules/fsm-iterator/lib/index.js:91:37)
          at next ([...]/node_modules/@redux-saga/core/dist/redux-saga-core.dev.cjs.js:1155:27)

I’m using :

  "redux-saga": "1.0.0",
  "redux-saga-test-plan": "4.0.0-beta.3",

Am I missing something ?