Giter Site home page Giter Site logo

redux-saga-test-engine's Introduction

Redux Saga Test Engine

npm Code scanning - action

Test your redux-saga generator functions with less pain.

Contents

Installation

With npm:

npm install redux-saga-test-engine --save-dev

With yarn:

yarn add redux-saga-test-engine --dev

Basic Usage

const { createSagaTestEngine } = require('redux-saga-test-engine')

// Choose which effect types you want to collect from the saga.
const collectEffects = createSagaTestEngine(['PUT', 'CALL'])

const actualEffects = collectEffects(
  // This is the saga we are testing.
  sagaToTest,

  // The environment mapping of redux effect calls to their corresponding yielded value.
  // If the the collector function encounters a non-`put` yielded in the saga,
  // it needs to be told what to yield. Worth noting here that the order does NOT
  // matter, as long as you don't have duplicate keys.
  [
    [select(getPuppy), { barks: true, cute: 'Definitely' }],
    [call(API.doWeLovePuppies), { answer: 'Of course we do!' }]
  ],

  // Optional. All remaining arguments are given direct arguments to `sagaToTest` itself.
  // Typically it is the action that triggers the saga worker function.
  initialAction
)

actualEffects
// [
//   call(API.doWeLovePuppies),
//   put(petPuppy(puppy)),
//   put(hugPuppy(puppy))
// ]

Full Example

// favSaga.js
function* retryFavSagaWorker(action) {
  const { itemId } = action.payload
  const { token, user } = yield select(getGlobalState)

  let attempt = 0
  while (attempt++ < 5) {
    try {
      const response = yield call(favItem, itemId, token)
      const json = yield response.json()
      yield put(successfulFavItemAction(json, itemId, user))
      break
    } catch (e) {
      yield put(receivedFavItemErrorAction(e, itemId))
      yield delay(2000)
    }
  }
}
// favSaga.spec.js
const test = require('ava')
const { collectPuts, stub, throwError } = require('redux-saga-test-engine')
const {
  retryFavSagaWorker,
  getGlobalState,
  favItem,
  successfulFavItemAction,
  receivedFavItemErrorAction,
} = require('../sagas')

const { delay } = require('redux-saga')
const { select, call, put } = require('redux-saga/effects')

test('retryFavSagaWorker', t => {
  const itemId = '123'
  const token = '456'
  const user = { id: '321' }

  const favItemResp = 'The favItem JSON response'
  const favItemRespObj = { json: () => favItemResp }

  const favItemRespFail = new TypeError('TypeError: response.json is not a function')
  const favItemRespObjFail = { json: () => { throw favItemRespFail } }

  const FAV_ACTION = {
    type: 'FAV_ITEM_REQUESTED',
    payload: { itemId },
  }

  const ENV = [
    [select(getGlobalState), { user, token }],
    [call(favItem, itemId, token), stub(function* () {
      yield favItemRespObjFail
      yield favItemRespObj
    })],
    [delay(2000), '__elapsed__']
  ]

  const actual = collectPuts((retryFavSagaWorker), ENV, FAV_ACTION)
  const expected = [
    put(receivedFavItemErrorAction(favItemRespFail, itemId)),
    put(successfulFavItemAction(favItemResp, itemId, user)),
  ]

  t.deepEqual(
    actual,
    expected,
    'We should see the `receivedFavItemErrorAction` and `successfulFavItemAction` dispatched with the correct information'
  )
})

API

const {
  // Creates a collector function to collect arbitrary effects.
  // Example:
  //    const getPuts = createSagaTestEngine(['PUT'])
  createSagaTestEngine,

  // Convenient pre-filled collector functions to collect PUTs, CALLs, or both.
  collectPuts,
  collectCalls,
  collectCallsAndPuts,

  // Helper method.
  // If used as a value in the mapping, it throws an error inside the saga function
  // when the corresponding effect is found in the saga. If inside a try-catch,
  // the argument provided to throwError will be passed to the catch function.
  throwError,

  // Helper method.
  // When used as value in the mapping, it can return different values on each call,
  // defined by passed generator function.
  stub,
} = require('redux-saga-test-engine')

FAQ

Q: What's the deal with this?

A: It's annoying to test sagas. To do them by hand, you have iterate through the generator function by hand, passing in the next value to continue it along. This makes the tests much more verbose than the sagas themselves, in which case you are more likely to have bugs in the saga tests than the sagas. It's also very dependent on the exact order yields occur in the saga, which make them unnecessarily brittle.

This library has the understanding that the main thing you care about testing for your sagas is what actions are dispatched (ie your yield put(...)'s), and in what order. Your selects, calls, etc can be thought of as your "inputs", and the puts can be thought of as the "outputs" of your saga.

Therefore, the arguments to the engine provided is:

  1. The function you are testing,
  2. A "map" of your environment along with their resulting values, and
  3. Whatever other arguments should initialize the saga worker (optional).

...and the output is an array of put(...) effect objects as they occur.

Q: How to test saga that is expected to throw exception?

A: In some cases is useful saga to throw exceptions, for example when it is part of bigger composed saga chain. As this library is testing framework agnostic it should propagate saga exceptions up and this makes it no longer possible to receive collected 'PUT's as function result. To solve this problem we can pass empty collected array as argument to collectPuts function and inspect the content after the test run. The second argument (the envMapping) can accept options object, see the following ava test example:

test('Example throwFavSagaWorker with throwError effect follows sad path', t => {
  const FAV_ACTION = {
    type: 'FAV_ITEM_REQUESTED',
    payload: { itemId: 123 },
  }

  const mapping = [
    [call(favItem, itemId, token), throwError('ERROR')],
  ]

  // empty array reference
  const collected = []

  const options = {
    mapping,
    collected,
  }

  // expect to throw exception
  t.throws(() => {
    collectPuts(throwFavSagaWorker, options, FAV_ACTION)
  })

  t.deepEqual(
    collected,
    [put(receivedFavItemErrorAction('ERROR', 123))],
    'Not happy path'
  )
})

Q: Why not just use redux-saga-test?

A: Lets see how one uses it:

const fromGenerator = require('redux-saga-test');

test('saga', (t) => {
  const expect = fromGenerator(t, testSaga()) // <= pass your assert library with a `deepEqual` method.

  expect.next().put({type: 'FETCHING'})
  expect.next().call(loadData)
  expect.next(mockData).put({type: 'FETCHED', payload: mockData})
  expect.next().returns()
})

It's great that it cuts down on verbosity. But, as you can see, the exact order of the yielded Call and Put effects in the saga matter for the test, and then mockData has to be passed into the right spot (notably, in the next(mockData) after the call(loadData), which is the correct but confusing ordering). That makes them more brittle than necessary, and not as declarative as possible. Also you have to directly insert your assertion library with deepEqual library, which is a bit magical.

Q: Why not just use redux-saga-test-plan?

A: Largely the same reasons as for redux-saga-test above. To the example usage!

saga
  .next() // advance saga with `next()`
  .take('HELLO') // assert that the saga yields `take` with `'HELLO'` as type
  .next(action) // pass back in a value to a saga after it yields
  .put({ type: 'ADD', payload: 42 }) // assert that the saga yields `put` with the expected action
  .next()
  .call(identity, action) // assert that the saga yields a `call` to `identity` with the `action` argument
  .next()
  .isDone(); // assert that the saga is finished

Again, annoyingly needs to handle the next manually, passing in the next value. Depending on exact ordering is a drag. So is manually inserting the generated value into the next next. Not recommended, would not test with again.

Q: Why not just do it manually (example)?

A: Sure, if you want. It's just tedious and brittle for the same reasons mentioned in the previous two questions.

it('should cancel login task', () => {
  const generator = loginFlow()
  assert.deepEqual(
    generator.next().value,
    take('LOGIN_REQUEST'),
    'waiting for login request'
  )

  const credentials = { name: 'kitty', password: 'secret' }
  assert.deepEqual(
    generator.next(credentials).value,
    fork(authorize, credentials.user, credentials.password),
    'authorizing user'
  )

  const task = createMockTask()
  assert.deepEqual(
    generator.next(task).value,
    take([ 'LOGOUT', 'LOGIN_ERROR' ]),
    'waiting for logout or login error'
  )

  const action = { type: 'LOGOUT' }
  assert.deepEqual(
    generator.next(action).value,
    cancel(task),
    'cancelling login'
  )

  assert.deepEqual(
    generator.next().value,
    call(clearSession),
    'clearing session'
  )
})

Q: Why not use a Map for the second argument (the envMapping)?

A: NOTE: The collector functions now accept a Map as well as a nested array. But it isn't actually helpful, as described below.

Maps only work if the key is referencing the identical object (ie a === b), even if their values are the same (ie deepEqual(a, b)). Thus a corresponding select(...) value, for example, would not be found merely by using envMap.get(select(...)). Instead, the keys must be traversed though - and so it's no more helpful to use a Map than a simple nested Array.

Q: I know a better way.

A: Awesome, please show us!

License

MIT

redux-saga-test-engine's People

Contributors

corydanielson avatar dependabot[bot] avatar hmillison avatar sachmata avatar timbuckley avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

redux-saga-test-engine's Issues

Improve error message if nested array is incorrect

If a user messes up their nested array in collectPuts, the error message provided is not that helpful as it's written to explain incorrect use of types, but not why a specific assertion failed:

assert(
isMap(envMapping) || isNestedArray(envMapping),
'The second parameter must be a nested array, ' +
'Map or object containing the same under `mapping` key'
)

It would be better if a secondary assertion for the nested array was added to explicitly explain why the array approach failed with an error message of something like:

"The nested arrays must have exactly two arguments".

redux-saga-test-plan has also integration testing

In your comparison with other testing libs you only compare the unit type approach of redux-saga-test-plan. By now it also has integration like behaviour:

it('handles reducers', () => {
  return expectSaga(saga)
    .withReducer(dogReducer)

    .put({ type: AGE_BEFORE, payload: 11 })
    .put({ type: AGE_AFTER, payload: 12 })

    .dispatch({ type: HAVE_BIRTHDAY })

    .run();
});

I will try both and let you know my findings.

2.0.3 published to npm incorrectly?

I'm trying to upgrade to 2.0.3 so that I can update tests with specific params using throwError, but it doesn't seem to be installing the correct code. The built code that is downloaded appears to be an older version of the library, because I can see the old code for throwing errors

    if (_throwError) {
      genResult = gen.throw('ERROR');
    } else {
      genResult = gen.next(nextVal);
    }

that code should lookup the value in nextVal

    if (throwError) {
      genResult = gen.throw(nextVal[throwErrorKey] || 'ERROR')
    } else {
      genResult = gen.next(nextVal)
    }

Unable to pass `undefined` as mock return value

Currently an Env mapping is missing a value for... error will be thrown when a mapping specifies undefined as its return value, ie

[call(shouldReturnUndefined), undefined]

due to the check on line 164 of core.js:

var nextValFound = nextVal !== undefined;

I believe undefined is a valid return value, so this throw condition seems to be in error. The absence of a return value should instead be caught by isNestedArray, which confirms the array has a length of 2.

My suggested fix would be to remove line 164 (happy to do a PR!), but raising an issue to confirm there's not some other reasoning behind this assertion.

TODO: Handle array of PUT effects

Sometimes an array of PUT effects may be yielded. The test engine should treat it identically to a single PUT in terms of the the final returned puts array.

Example

yield [
  put(actionOne()),
  put(actionTwo())
]

Also, a single array of puts, selects, calls, etc may be yielded at once. Must consider how best to handle this event.

yield [
  put(actionOne()),
  select(something),
  call(somethingElse),
  put(actionTwo())
]

Change of repo ownership

As you may have heard in the news, DNAinfo has been shut down, and so the github org hosting this library may also be closed soon.

Accordingly I am getting this repo moved to my github account (timbuckley/redux-saga-test-engine). This should not affect you if you just want to use the library on npm (ie npm i --save-dev redux-saga-test-engine), but will if you depend on the git repo directly (which is not recommended anyway).

Just wanted to make all interested parties aware of this change. :-)

TODO: Compile ES5-version for build for lower Node version compatibility.

To make this library useable in various node versions, the ES6 src/ code should be compiled to ES5 into a build/ directory, rather than deploying the ES6 code directly as it currently does.

  • A build process should be set up (using babel, etc).
  • This build version should go to NPM.

I don't think this should affect versioning but I'm open to other opinions.

TODO: Handle yield*

It's a little unclear about what the expected behavior of yield* should be handled in a testing paradigm.

TODO: Need mechanism to throw values into the generator during tests

Redux Saga will throw rejected promise values into the generator which will cause jump to the catch block in a try catch. Currently, we cannot test this catch block without some very hacky code in the sagaEnv.

function* mySaga() {
  try {
    yield call(doPromiseThing);
    yield put(alert, 'All good!')
  } catch (e) {
     yield put(alert, e);
  }
}

If you were to test this saga with a sagaEnv where the doPromiseThing returns a rejected promise, it would not throw the rejected value into the saga. It would call gen.next with the promise, and then continue onto the All good! alert call.

https://github.com/DNAinfo/redux-saga-test-engine/blob/64b3732eb1192df603378ccae603a21e2c7fb1b9/src/index.js#L85

Env Mapping is missing a value for a PUT?

Just diving into testing sagas so bear with me if this is obvious.

My understanding is collectPuts shouldn't need env mappings for PUTS or am I just sadly mistaken?

Env Mapping is missing a value for {
      "@@redux-saga/IO": true,
      "combinator": false,
      "type": "PUT",
      "payload": {
        "action": {
          "type": "authorization/UPDATE_TOKEN",
          "payload": "mockAuthorizationToken"
        }
      }
    }

My saga is just a select() and yields put(). Something like:

export default function* onSuccess(response) {
    const previousToken = yield select(selectAuthorizationToken);

    yield put(updateToken(...))

    return response; // response interceptor stuff requires this
}

can't import library: Couldn't find preset "env"

I wanted to give your library a try in a react-native (0.45) project, but as soon as I import something from it, the test fails with the following error:

Couldn't find preset "env" relative to directory "[snip]/app/node_modules/redux-saga-test-engine"

There doesn't seem to be any documented requirements regarding build configuration from your side, so I'm not sure if I'm doing something wrong or if this is an error in the library setup.

Please let me know if you need any more input.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.