redux-observable / redux-observable

RxJS middleware for action side effects in Redux using "Epics"
https://redux-observable.js.org
MIT License
7.85k stars 466 forks source link

Testing async operators such as `delay()` #180

Closed spiicy-sauce closed 6 years ago

spiicy-sauce commented 7 years ago

So I just spent a questionable amount of time trying to test an epic that has a delay in it, and after many breakpoints in source code and lots of head scratching, I figured out a solution that I think would be a helpful start for people trying to do the same thing. (I couldn't find any reference to this kind of test on the interwebs). The epic I was trying to test is pretty straightforward:

export default (action$) => {
  return action$
    .ofType(CREATE_ALERT)
    .filter(({payload}) => payload.alert.get('timeout'))
    .mergeMap(({payload}) => {
      const { alert } = payload;
      return Observable.of(alert.get('id'))
        .delay(alert.get('duration'))
        .map((id) => {
          return removeAlert(id);
        });
    });
};

It just looks for alert creation actions and emits tear-down actions after duration time if timeout is true. So I tried to use the example here to do so. I started by making a function to get a test scheduler (for reference, I'm using jasmine for testing):

const getTestScheduler = () => {
  return new TestScheduler(
    (actual, expected) => {
      expect(actual).toEqual(expected);
    }
  );
};

And then made a (bare bones) version of the expectEpic method from the example I linked to above:

// import { ActionsObservable } from 'redux-observable';
// import { TestScheduler, Observable } from 'rxjs'
export const expectEpic = (epic, {action, expected}) => {
  const testScheduler = getTestScheduler();
  const action$ = testScheduler.createHotObservable(...action);
  const test$ = epic(new ActionsObservable(action$));
  testScheduler.expectObservable(test$).toBe(...expected);
  testScheduler.flush();
};

When I finally ran a pretty simple marble test:

it('tears down alerts after the scheduled duration', (done) => {
    const actionState = {
      a: createAlert(testAlertWithTimeout)
    };
    const expectedState = {
      b: removeAlert(testAlertWithTimeoutId)
    };
    // I used a duration of 40ms
    const actionMarbles   = 'a---a----|';
    const expectedMarbles = '----b---b|';
    const action = [actionMarbles, actionState];
    const expected = [expectedMarbles, expectedState];
    expectEpic(teardownAlertsEpic, {
      action,
      expected
    });
  done();
});

I got the error: Expected [ ] to equal [ Object({ frame: 40, ...

No actions were actually emitted by the time flush was called because the epic was getting hung up on the delay (this happens if you add a delay to the JSBin example linked to from the redux-observable website as well: here). This was honestly the thing that I couldn't figure out for a really long time, until I remembered a document about testing with Rxjs4 that I had skimmed over, which mentions injecting schedulers into operators. The issue was that delay was being scheduled completely separately from the test. So the epic, by the time the test flushed, had not actually emitted any actions.

Ultimately the solution to my problem was to inject my testScheduler into the delay operator at test-time.

const injectTimeBasedOperators = (testScheduler) => {
  const originalDelay = Observable.prototype.delay;
  function stubDelay(dueTime) {
    return originalDelay.call(this, dueTime, testScheduler);
  }
  spyOn(Observable.prototype, 'delay').and.callFake(stubDelay);
};

Which injects the test scheduler into time-based operators (although right now I only have delay). This method can be called right after the testScheduler is created in expectEpic and voila, the delay operator is now scheduled by testScheduler.

I hope my day of struggling helps at least one person with a similar problem. I'm really interested in seeing redux-observable and rxjs testing utilities develop even further. Being able to easily test complex async flows is absurdly powerful as a developer.

connected-mgosbee commented 7 years ago

Thank you for making this post, it's very helpful, as I'm also trying to test asynchronous epic's.

vyorkin commented 7 years ago

Hey, @tcclevela! Thank you for sharing your thoughts, I've learned a lot from this and other issues. Here is a very naive "ping-pong" sketch of what I've came up with for the current project I'm working on: https://gist.github.com/vyorkin/2b344e16e17be480e7065c5067c9e30e, another example: https://gist.github.com/vyorkin/9ee747744802f31ae2881d2fd2db61e0 (a bit outdated now, I've changed a lot yesterday night)

rrcobb commented 7 years ago

I got something that seems a little bit simpler for high-level testing epics with async operators (using Jasmine). I'd love thoughts on this approach - it feels a little sneaky to reach into the core Observable.prototype, but maybe okay?

epic.js

const suggestionsEpic = (action$) => {
  // assume some input is emitting these update text actions
  let changes$ = action$.ofType('update_text')

  // do some fetches and map to a success action
  let suggestionFetches$ = changes$
    .debounceTime(200)
    .flatMap(action => Observable.from($.get('/suggest', { q: action.data.q })
      .map(response => ({type: 'suggestion_fetch_success', data: response.data }))
    )

  return suggestionFetches$
}

test.js

import { Observable } from 'rxjs'
import mockStore from 'redux-mock-store'

describe('suggestionsEpic', () => {
  let middleware, store, response
  beforeEach(() => {
    spyOn(Observable.prototype, 'debounceTime').and.callFake(function () {
       return this
    })
    let mockStore = createMockStore([createEpicMiddleware(suggestionsEpic)])
    store = mockStore({})
    response = { data: ['a', 'b', 'c'] }
    spyOn($, 'get').and.returnValue(Promise.resolve(response))
 })

 it('on text update, calls the api and emits the right action on success', (done) => {
    let action = {type: 'update_text', data: { q: 'test search' }}
    store.dispatch(action).then(() => {
      expect(store.getActions()).toEqual([action,
        {type: 'suggestion_fetch_success', data: response.data }])
      done()
    })
    expect($.get).toHaveBeenCalledWith('/suggest', { q: 'test search' })
  })
})

The key moment being:

spyOn(Observable.prototype, 'debounceTime').and.callFake(function () {
  return this
})

I spied on debounce instead of delay, but it should work for any of the async methods

jayphelps commented 7 years ago

@rrcobb stubbing stuff on the prototype for testing isn't that bad, your solution seems pretty fair. The issue is mostly that you aren't asserting that the time really did pass as expected. Your approach could be extended to do that as well, though.

RxJS TestScheduler would ideally solve these, but none of us have had cycles to fix it. 😢

hally9k commented 6 years ago

@jayphelps Is there any update on the RxJS TestScheduler mentioned above? In lieu of the tooling has there been any further work around 'best practice' for mocking out the test scheduler?

krzysztofzuraw commented 6 years ago

If you are using rxjs 6 and redux-observable 1.0.0+ use can use almost the same pattern as @rrcobb :

import { of } from 'rxjs';
import * as operators from 'rxjs/operators';

spyOn(operators, 'delay').and.returnValue(() => of([]));
jayphelps commented 6 years ago

Good news: we made the new testScheduler.run(callback) in rxjs v6 to make things much easier: https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md tl;dr it provides automatic usage of the TestScheduler instance for async operators (no need to inject it), provides a new time progression syntax e.g. a 100ms b, and changes the value of a single frame from 10ms to 1ms.

The redux-observable docs also mention it: https://redux-observable.js.org/docs/recipes/WritingTests.html though the meat of the documentation is still the rxjs doc listed above.

acailly commented 5 years ago

I found a workaround that allow me to test a code wich use the delay() operator with jest faketimers and without having to do marbles: https://stackoverflow.com/a/56478886/5251198