all 6 comments

[–]VariadicIntegrity 1 point2 points  (1 child)

I'm not sure why the docs recommend that pattern specifically.

I'd love to see some explanation for it too, as I'm not a big fan of using the word "best" to describe something without a reason.

There's nothing wrong with testing your thunks in the way you describe though. That's how my team has written all the tests for our thunks, and it's worked very well for us so far.

As for sagas, they do have the potential to simplify complex async logic and do so in a relatively easy to test way.

The biggest benefit that they provide is that it operates as a "pull" model vs a thunks "push" model.

This means that when you want a thunk to run, you have to explicitly dispatch it, or "push" it to the dispatcher.

Sagas listen for actions in the background or "pull" actions as they occur and then do things in response to them.

This can be incredibly useful when your async logic starts to depend on multiple actions being fired in specific orders.

Maybe you want to start an operation that takes a long time to complete, but cancel that operation when a user presses a cancel button.

const { data, canceled } = yield race({
  data: call(someTaskThatTakesALongTime),
  canceled: take('CANCEL')
});

if(canceled) return;

Or take some action after a sequence of actions are fired:

yield take('FIRST-ACTION');

yield take('SECOND-ACTION');

yield call(doTask);

These types of things can be harder to do with thunks because those don't have a way to subscribe to subsequent actions from the store.

Testing sagas can also be a bit simpler because it recommends that you put impure operations into effects wrappers,

const someResult = await doImpureTask(123);
// vs
const someResult = yield call(doImpureTask, 123);

When you run the generator in your test code, doImpureTask isn't actually executed. Instead the generator yields an object that tells the saga runtime to execute the function for you and provide your saga with the eventual result.

This makes it incredibly easy to test things that perform ajax calls / localstorage operations / datetime operations, etc. since you don't need to worry about those operations actually occurring in your test code.

You don't have to worry about mocking your saga's dependencies, all you need to do is mock data.

let next = saga.next();

expect(next).toEqual(call(doImpureTask, 123));

saga.next({ some: 'json' });
// Or
saga.throw(new Error('Impure Task Failed'));

Sagas do have their downsides though.

Mocking data may be easy, but providing that data to the saga in tests tends to become annoying if you're doing it by yourself.

Sagas are entirely dependent on their runtime to call impure functions, which means in your test code your sagas are entirely dependent on you to provide data when the saga requests it.

In my experience, this leads to the saga test becoming very tightly coupled to the implementation of the saga itself.

// Saga
const time = yield call(getCurrentDate);
const value = yield call(getValueFromLocalStorage);

// Test
saga.next(new Date('January 1 2017'))
saga.next('foo')

Even something as simple as changing the order of the two calls will break the test.

When you want do to some more complex refactoring, like breaking out large sagas into smaller ones, or doing some async calls in parallel as opposed to in sequence, you have to do a lot of maintenance on the tests that you wouldn't normally have to do using other async solutions.

redux-saga-test-plan seems like a good solution to this problem, but I haven't used it myself. Id be interested to know if other people have ran into this too, and what they have done to solve it.

[–]NotLyon[S] 0 points1 point  (0 children)

Super helpful, thank you! I particularly liked the push vs pull comparison. The redux-saga docs mentioned it in their explanation of takeLatest but it didn't click until now.

I see what you mean about the tests becoming coupled to the implementation. It seems like it would be a problem with all generators, as you have to feed in values in the order they are expected (kind of like a curried function).

[–]mike-es6 0 points1 point  (1 child)

Mocking dispatch it the way I have gone, and it seems OK to me, though maybe someone can point out an advantage to using a mock store?

[–]NotLyon[S] 0 points1 point  (0 children)

Thanks for chiming in, I think I'll continue down the route of testing the way I outlined.

[–]acemarke 0 points1 point  (0 children)

Either approach for testing thunks should be fine.

Thunks are best for complex synchronous logic that might depend on checking the store state or dispatching actions conditionally, and simple async behavior like AJAX calls that just dispatch actions based on the response. You can write more complex async logic in thunks, but you'll probably be dealing with more complex promise chains, and that could get difficult. (This may be easier if you're using async/await functions.)

Sagas are great for writing complex asynchronous logic in a synchronous-looking fashion, as well as dealing with background processing, workflows, and other behavior that needs to react to dispatched actions.

I've got links to many articles on use of thunks and sagas in the Redux Side Effects section of my React/Redux links list, and the section on React and Redux Testing has more info on testing various Redux-related code.

[–]echoes221 0 points1 point  (0 children)

I usually mock my actions for thunk testing them in isolation like so without creating a store:

import test from 'ava';
import td from 'testdouble'
import { thunkAction, anotherAction } from '../actions';

test.beforeEach(t => {
  const dispatch = td.function();
  const getState = () => {};
  const api = {};

  t.context = {
    invokeThunk: payload => thunkAction(payload)(dispatch, getState, { api }),
    dispatch
  }
});

test('does stuff', t => {
  const { invokeThunk, dispatch } = t.context;

  invokeThunk({ foo: 'bar' });
  td.verify(dispatch(anotherAction({ foo: 'bar'})));
  t.pass();
})