3.7.0 • Published 8 months ago

saga-test-stub v3.7.0

Weekly downloads
-
License
MIT
Repository
-
Last release
8 months ago

Redux Saga Test Stub

What you want

  • Test your sagas like you would do with non-saga functions
  • Test complex sagas (branches, loops, and whatnot) like this one:
export function* drive(destination: string): Iterator<Effect, string, any> {
    const origin = yield select(currentPosition)
    const route = yield call(getRoute, origin, destination);
    if (route === 'unknown') {
        return 'ask for direction';
    }
    let position = origin;
    do {
        if (position == origin) {
            yield put({ type: 'at origin' });
        }
        const lights = yield select(trafficLight);
        if (lights.green == true) {
            yield put({ type: 'keep driving' });
        }
        if (lights.red == true) {
            yield put({ type: 'apply brakes' });
        }
        if (lights.yellow == true) {
            const distance = yield select(distanceToLine);
            if (distance < 1) {
                yield put({ type: 'apply brakes' });
            }
            else {
                yield put({ type: 'keep driving' });
            }
        }
        position = yield select(currentPosition);
        if (position == 'bermuda triangle') {
            throw new Error('we are lost');
        }

    } while (position != destination);
    return 'we are at destination';
}

What you get

Stubs

  • when(saga).yields(effect | (effect)=>boolean).doNext(...values) tell to tester what to return when iteration yields an effect
  • when(saga).selects(selector,...args).doNext(...values) shortcut to when(saga).yields(select())
  • when(saga).calls(fn,...args).doNext(...values) shortcut to when(saga).yields(call())
  • when(saga).yields(effect | (effect)=>boolean).integrate(sagaStub) tell to tester to run a saga when iteration yields one (integration test)
  • when(saga).yields(effect | (effect)=>boolean).throw(Error) tell to tester to throw an Error

Assertions

  • run(saga).expecting(toYield(...effects | ...(effect)=>boolean)) run saga and expect effects to be yielded in order
  • run(saga).expecting(...effects | ...(effect)=>boolean) same as expecting(toYield(effects))
  • run(saga).expecting(toYieldStrict(...effects | ...(effect)=>boolean)) run saga and expect effects to be yielded strictly in order
  • run(saga).expecting(toBeDoneAfter(effect | (effect)=>boolean)) run saga, expect effect to be yielded and to be done after
  • run(saga).expecting(toReturn(value | (value)=>boolean)) run saga and expect return value when done
  • run(saga).expectingDone((value | (value)=>boolean)?) same as expecting(toReturn()), value is optional
  • run(saga).expecting(toThrowError(message)) run saga and expect an error to be thrown
  • run(saga).expecting(not(qualifier))) can be used with one of the previous assertion to negate it

Jest assertions

  • expect(saga).toYield(...effects | ...(effect)=>boolean) same as run(saga).expecting(toYield())
  • expect(saga).toPut(...actions) shortcut for expect.toYield(...put())
  • expect(saga).toCall(fn,...args) shortcut for expect.toYield(call(fn,args))
  • expect(saga).toYieldStrict(...effects | ...(effect)=>boolean) same as run(saga).expecting(toYieldStrict())
  • expect(saga).toBeDoneAfter(effect | (effect)=>boolean) same as run(saga).expecting(toBeDoneAfter())
  • expect(saga).toYieldLast(...effects | ...(effect)=>boolean) same as toBeDoneAfter but with a list of effects
  • expect(saga).toReturn(value | (value)=>boolean) run saga and expect return value when done
  • expect(saga).toBeDone((value | (value)=>boolean)?) same as toReturn, value is optional

Note: with jest you can use matchers like expect.objetContaining and expect.arrayContaining

What your unit tests would be

First initialize your saga

import { stub } from "saga-test-stub";
//with jest: import { stub } from "saga-test-stub/jest";

const saga = stub(drive);

Stub effects

import { when } from "saga-test-stub";
//with jest-when: import { when } from "saga-test-stub/jest-when";

when(saga).yields(call(getRoute, 'point A', 'point D')).doNext('go to B, then C and you will be at D');
when(saga).yields(select(trafficLight)).doNext(lights);

Do assertion

run(saga).expecting(toYield(put({ type: 'keep driving' })));
run(saga).expecting(toReturn('ask for direction'));

//jest
expect(saga).toYield(put({ type: 'keep driving' }));
expect(saga).toReturn('ask for direction');

Shorcuts

when(saga).calls(getRoute, 'point A', 'point D').doNext('go to B, then C and you will be at D');
when(saga).selects(trafficLight).doNext(lights);

//jest
expect(saga).toYield(put({ type: 'keep driving' }));
expect(saga).toSelect(distanceToLine);

Error handling

when(saga).selects(trafficLight).throw(new Error('no electricity, do a hard stop'));

run(saga).expecting(toThrowError('we are lost'));

//for jest, you'll get more debug information using run(saga), but if you absolutely want to do expect(), this is an option:
expect(() => saga.run()).toThrowError('we are lost');

Branches

When testing a positive branch, you have to make sure to test the negative one, ex:

const a = yield select(something);
if (a == 1){
  yield put(someAction);
}

//test
when(saga).selects(something).doNext(1);
run(saga).expecting(toPut(someAction)); //positive branch

If you don't test the negative branch (a!=1), the previous test will be successful even if you delete the if

const a = yield select(something);
// if (a == 1){
  yield put(someAction);
// }

So, you should also have in your suite:

when(saga).selects(something).doNext(2);
run(saga).expecting(not(toPut(someAction)));

but this is still weak, there is a lot of way to break the code and you're test will be successful, so better use toBeDoneAfter or toYieldStrict

Use toBeDoneAfter if there is no yield after the branch

const a = yield select(something);
if (a == 1){
  yield put(someAction);
}
return;

//negative branch
when(saga).selects(something).doNext(2);
run(saga).expecting(toBeDoneAfter(select(something)));

Use toYieldStrict if there is a yield after the branch

const a = yield select(something);
if (a == 1){
  yield put(someAction);
}
yield take(aBreak);

//negative branch
when(saga).selects(something).doNext(2);
run(saga).expecting(toYieldStrict(select(something),take(aBreak)));

Loops

stubbing allows to give a list of effects to yield, so to simply test the drive saga, you can do:

when(saga).yields(select(currentPosition)).doNext('point A', 'point B', 'point C', 'point D');

in this the saga will go 4 times trough the loop

Complete test

describe('demo', () => {
    const saga = stub(drive, 'point D');
    let lights: any;

    beforeEach(() => {
        when(saga).yields(select(currentPosition)).doNext('point A', 'point B', 'point C', 'point D');

        lights = { green: false, red: false, yellow: false }
        when(saga).selects(trafficLight).doNext(lights);
    });

    describe('when route is unknown', () => {
        beforeEach(() => {
            when(saga).yields(call(getRoute, 'point A', 'point D')).doNext('unknown');
        });

        it('should do nothing after asking for route', () => {
            run(saga).expecting(toBeDoneAfter(call(getRoute, 'point A', 'point D')));
        });

        it('should return cannot drive there', () => {
            run(saga).expecting(toReturn('ask for direction'));
        });
    });

    describe('when a route is found', () => {
        beforeEach(() => {
            when(saga).yields(call(getRoute, 'point A', 'point D')).doNext('go to B, then C and you will be at D');
        });

        describe('when traffic light is green', () => {
            beforeEach(() => {
                lights.green = true;
            });

            it('should keep driving', () => {
                run(saga).expecting(toYield(put({ type: 'keep driving' })));
            });
        });

        describe('when traffic light is yellow', () => {
            beforeEach(() => {
                lights.yellow = true;
            });

            describe('when distance is less than 1', () => {
                beforeEach(() => {
                    when(saga).selects(distanceToLine).doNext(0.9);
                });

                it('should apply brakes', () => {
                    run(saga).expecting(toYield(put({ type: 'apply brakes' })));
                });
            });
            describe('when distance is more than 1', () => {
                beforeEach(() => {
                    when(saga).selects(distanceToLine).doNext(1.1);
                });

                it('should keep driving', () => {
                    run(saga).expecting(toYield(put({ type: 'keep driving' })));
                });
            });
        });

        describe('when route goes by bermuda triangle', () => {
            beforeEach(() => {
                when(saga).yields(select(currentPosition)).doNext('point A', 'bermuda triangle', 'point D');
            });

            it('should throw an error', () => {
                run(saga).expecting(toThrowError('we are lost'));
            });
        });
    });
});

What the tester really do

Simply tries to go as far as possible in the iteration with the stub information provided until it match expectation

What it looks like when my code is broken

You get a report with yielded effects and what action the tester took (next() or next(stubbedValue))

 FAIL  tests/saga.spec.ts
  fritkot saga with jest
    when sadly closed
      ✓ should wait (16 ms)
      ✓ should be done (7 ms)
    when open for business
      ✓ should ask for the bill and thank the chef (13 ms)
      ✓ should ask the bill (11 ms)
      ✓ should thank the chef (10 ms)
      when world is sad and there is no more hot sauces
        ✓ should ask for non hot sauce and pick the first one (11 ms)
      when there is Samourai sauce
        ✕ should ask for Samourai (17 ms)
      when there is not Samourai sauce
        ✓ should ask for the second one (10 ms)
      when there is 2 fries left
        ✓ should eat the fries and be sad (28 ms)

  ● fritkot saga with jest › when open for business › when there is Samourai sauce › should ask for Samourai

    Expected effects were not yielded, this happened:
     YIELD  {"@@redux-saga/IO": true, "combinator": false, "payload": {"args": [], "selector": [Function getFritkot]}, "type": "SELECT"}
     NEXT   ({"open":true})
     YIELD  {"@@redux-saga/IO": true, "combinator": false, "payload": {"action": {"type": "Frites"}, "channel": undefined}, "type": "PUT"}
     NEXT   ()
     YIELD  {"@@redux-saga/IO": true, "combinator": false, "payload": {"args": [true], "context": null, "fn": [Function getSauces]}, "type": "CALL"}
     NEXT   (["Piri-piri","Samourai"])
     YIELD  {"@@redux-saga/IO": true, "combinator": false, "payload": {"action": {"type": "Samourai"}, "channel": undefined}, "type": "PUT"}
     NEXT   ()
     YIELD  {"@@redux-saga/IO": true, "combinator": false, "payload": {"args": [], "selector": [Function isPlateEmpty]}, "type": "SELECT"}
     NEXT   (true)
     YIELD  {"@@redux-saga/IO": true, "combinator": false, "payload": {"action": {"type": "Snif ! Y'a pu d'frite"}, "channel": undefined}, "type": "PUT"}
     NEXT   ()

      65 |
      66 |             it("should ask for Samourai", () => {
    > 67 |                 expect(saga).toPut({ type: 'Samoura' });
         |                              ^
      68 |             });
      69 |         });
      70 |

      at Object.<anonymous> (tests/saga.spec.ts:67:30)

What is in the future

  • add jasmine support

What is in the past

3.7.0

  • update core semantic: expecting(qualifier)
  • add core qualifiers:

    • toYield,
    • toYieldStrict
    • toBeDoneAfter
    • toReturn
    • toThrowError
    • possibility to negate all previous assertion with not()
  • add jest assertions

    • toYieldStrict
    • toBeDoneAfter
    • toReturn

3.6.0

  • jest 29.4 support

3.5.0

  • add throw capability to stubbing

3.4.1

  • fix/improve fail report

3.4.0

  • add expect.toYieldLast
  • fix expect.toPut signature (extends Action)
  • fix expect.toCall signature
  • fix error message when expection in saga
  • fix when() signature and generic (add parameter as function)

3.3.0

  • add when.yields.integrate

3.2.0

  • add shortcuts: when.selects, when.calls and expect.toCall

3.1.1

  • fix jest.not messaging
  • fix jest --expand messaging

3.1.0 (=3.0.2+fix semver)

  • fix peer dependencies
  • add jest-when support

3.0.1 (=3.0.0+README)

  • add typescript support
  • match effect with (effect)=>boolean
  • improve stringify(effect): reselect and other are not showing

2.0.0

  • add flexibility (match effects by function)

What if I need help?

dev.iam@techie.com

3.7.0

8 months ago

3.6.0

1 year ago

3.5.0

2 years ago

3.4.1

2 years ago

3.4.0

2 years ago

3.3.0

2 years ago

3.2.0

2 years ago

3.1.1

2 years ago

3.1.0

2 years ago

3.0.2

2 years ago

3.0.1

2 years ago

3.0.0

2 years ago

2.0.0

2 years ago

1.0.0

2 years ago

0.1.0

2 years ago