Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Testing error throw inside saga #147

Open
hidalgofdz opened this issue Sep 13, 2017 · 19 comments
Open

Testing error throw inside saga #147

hidalgofdz opened this issue Sep 13, 2017 · 19 comments

Comments

@hidalgofdz
Copy link

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.

@nicolasartman
Copy link

I also want to test a saga that, in a couple very specific failure cases, throws an exception. I have the test running smoothly but it still logs the error (even in 3.2.0). Since run() correctly returns the rejected promise, I'm not sure there's a need to allow any error logging in that case.

@jfairbank
Copy link
Owner

Hi @hidalgofdz!

The current problem is in how expectSaga works. It creates a saga function that sits between your real saga and redux-saga itself. Therefore, it can catch the error but has no control over what redux-saga does when it sees errors, which is print it with console.error. redux-saga doesn't know that it was already caught in expectSaga. It will take me some time to be able to address this, so if anyone else wants to take stab at investigating, please feel free. Basically, we need to prevent redux-saga from seeing the caught error.

@hedgerh
Copy link

hedgerh commented Nov 11, 2017

Hey @jfairbank

So I ran into an issue with testing error cases with testSaga as well. It seems that if you take a saga, pass it to a function, and do saga.throw('Error!') inside that function, essentially the same way testSaga does, jest tests fail because the function itself does not seem to catch it. so

function *mySaga () {
...
}

function testSaga(saga) {
  const throwError = (error) {
    return saga.throw(error);
  }

  return {
    throw: throwError
  }
}

describe('mySaga', () => {
  it('fails',() => {
    testSaga(mySaga())
    .throw('Error!')
  })
}

something like this, or exactly what testSaga does, causes a jest test to fail, unless testSaga itself is setup to catch throws, I guess?

So I guess its not quite the issue this person has, since it doesnt have anything to actually do with redux-saga, but its keeping us from testing error cases with testSaga.

So my questions:

  1. What test framework have you used that didnt cause tests to fail when using testSaga(...).throw(error)?

  2. Do you have any idea how we may mitigate this issue? If you have any ideas about a potential solution, I'd be glad to PR it. I may raise this issue up to jest, since my test case above seems to reveal its not a redux-saga-test-plan specific issue. I'm just hoping you may know a solution.

This is for a very large company, where I'm starting to push for redux-saga adoption company wide, so we'd probably have an interest in helping you maintain redux-saga-test-plan in the future. Regardless, if you have no idea for a solution, let me know and I will hunt down an answer somewhere else otherwise.

@jfairbank
Copy link
Owner

@hedgerh I believe this is JavaScript functioning as intended actually. If you run this code in Node or a browser, you'll get an uncaught exception.

function* saga() {}

saga().throw(new Error('whoops'))

Jest or any other test framework should see that as any other uncaught exception and rightfully blow up.

What error cases are you unable to test? I'm assuming you're using try-catch blocks for those cases in your saga?

That being said, I wouldn't be opposed to maybe catching the errors in testSaga to produce a more friendly error message. We'd still need to fail the test case by throwing the more friendly error, though.

Thoughts?

And of course, I'd definitely take any help your or your company could give. Most of my normal open source time has been consumed with other work that probably won't be finished until late winter or early spring 2018.

@ghost
Copy link

ghost commented Nov 20, 2017

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 })"
)

@hedgerh
Copy link

hedgerh commented Nov 27, 2017

thank you so much @VClav. this was exactly the issue I was having. I was immediately calling throw instead of calling next first. sweet. I can use the lib now!

@dowatugkins
Copy link

@VClav, you win the gold medal. In my case, I was calling next, but not enough times to get into my try block. I've been searching for this answer for DAYS! Thanks for explaining why calling next was actually important.

@dabrowne
Copy link
Contributor

dabrowne commented Jul 20, 2018

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.

@googamanga
Copy link

googamanga commented Nov 13, 2018

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');
    });
}

@hugomn
Copy link

hugomn commented May 21, 2019

@dabrowne did you find any way to omit the errors on your console when your saga throws an error?

@CadiChris
Copy link

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 ?

@euphocat
Copy link

@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();

@michaeltintiuc
Copy link

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');
 });

@CadiChris
Copy link

CadiChris commented Nov 15, 2019

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();
  });

@elfanos
Copy link

elfanos commented Jun 22, 2020

I managed to get it working for my use case, using this approach.

function* sagaWithError() {
  try{
   const response = yield call(someApi)
   return response
  }catch(error){
   throw Error(error)
 }
}

 it("should return some error", async () => {
    return expectSaga(sagaWithError, state)
               .provide({
                call(sagaEffect, next) {
                    if (sagaEffect.fn === sagaWithError) {
                        throw new Error("Some error");
                    }
                    return next();
                }
              })
             .run()

@cyruskong1
Copy link

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

@seanhuang514
Copy link

This is my solution

# code
* SSO (ssoType) {
  let payload = null;

  switch (ssoType) {
    case 'google':
      payload = yield call(google.login);
      break;
    case 'facebook':
      payload = yield call(facebook.login);
      break;
    default:
      throw Error(`${ssoType} does not support`);
  }
},
  
  # test
  
  expect(
    () => SSO('xxx').next()
  ).toThrowError(new Error(`${$ssoType} does not support`));
  
  # or
  
  try {
    SSO('xxx').next()
  } catch (error) {
    expect(new Error(`${$ssoType} does not support`));
  }
  
  # testsaga way
  
  expect(
    () => testSaga(SSO, 'xxx').next()
  ).toThrowError(new Error(`${$ssoType} does not support`));
  

@Yupeng-li
Copy link

The fix made in this pull request is no longer valid with redux-saga 1.1.3. d10dab5
logger: () => {},

According to this doc https://redux-saga.js.org/docs/api/#runsagaoptions-saga-args, logger is not supported anymore. A simple solution could be by setting onError to ()=>{}

@oyvindgrimen
Copy link

My workaround:

const mockConsole = jest.spyOn(console, "error").mockImplementation(() => null);
return expectSaga(someSagaThatThrows)
.throws(Error)
.run()
.finally(() => mockConsole.mockRestore());

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests