Platform: How to correctly test effects in ngrx 4

Created on 17 Oct 2017  路  8Comments  路  Source: ngrx/platform

I'm submitting a...


[ ] Regression (a behavior that used to work and stopped working in a new release)
[ X ] Bug report 
[ ] Feature request
[ X ] Documentation issue or request

[As posted on Stackoverflow - should I get an answer here or there, I'll close the question/issue accordingly)

There are plenty of tutorials how to test effects in ngrx 3.

However, I've found only 1 or 2 for ngrx4 (where they removed the classical approach via EffectsTestingModule ), e.g. the official tutorial

However, in my case their approach doesn't work.

effects.spec.ts (under src/modules/list/store/list in the link below)

 describe('addItem$', () => {
    it('should return LoadItemsSuccess action for each item', async() => {
      const item = makeItem(Faker.random.word);
      actions = hot('--a-', { a: new AddItem({ item })});

      const expected = cold('--b', { b: new AddUpdateItemSuccess({ item }) });
      // comparing marbles
      expect(effects.addItem$).toBeObservable(expected);
    });
  })

effects.ts (under src/modules/list/store/list in the link below)

...
 @Effect() addItem$ = this._actions$
    .ofType(ADD_ITEM)
    .map<AddItem, {item: Item}>(action => {
      return action.payload
    })
    .mergeMap<{item: Item}, Observable<Item>>(payload => {
      return Observable.fromPromise(this._listService.add(payload.item))
    })
    .map<any, AddUpdateItemSuccess>(item => {
      return new AddUpdateItemSuccess({
        item,
      })
    });
...

ERROR

should return LoadItemsSuccess action for each item
Expected $.length = 0 to equal 1.
Expected $[0] = undefined to equal Object({ frame: 20, notification: Notification({ kind: 'N', value: AddUpdateItemSuccess({ payload: Object({ item: Object({ title: Function }) }), type: 'ADD_UPDATE_ITEM_SUCCESS' }), error: undefined, hasValue: true }) }).
at compare (webpack:///node_modules/jasmine-marbles/index.js:82:0 <- karma-test-shim.js:159059:33)
at Object. (webpack:///src/modules/list/store/list/effects.spec.ts:58:31 <- karma-test-shim.js:131230:42)
at step (karma-test-shim.js:131170:23)

NOTE: the effects use a service which involves writing to PouchDB. However, the issue doesn't seem related to that
and also the effects work in the running app.

The full code is a Ionic 3 app and be found here (just clone, npm i and npm run test)

Most helpful comment

@dragGH102 Looks like this is an RxJS issue when using promises using marbles. https://stackoverflow.com/a/46313743/4148561

I did manage to do a bit of a hack which should work, however you will need to put a separate test the service is being called, unless you can update the service to return an observable instead of a promise.

Essentially what I did was extract the Observable.fromPromise call into its own "internal function" which we can mock to simulate a call to the service, then it looks from there.

This way you can test the internal function _addItem without using marbles.

Effect

import 'rxjs/add/observable/fromPromise';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';

import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';

export const ADD_ITEM = 'Add Item';
export const ADD_UPDATE_ITEM_SUCCESS = 'Add Item Success';

export class AddItem implements Action {
    type: string = ADD_ITEM;
    constructor(public payload: { item: any }) { }
}

export class AddUpdateItemSuccess implements Action {
    type: string = ADD_UPDATE_ITEM_SUCCESS;
    constructor(public payload: { item: any }) { }
}

export class Item {

}

export class ListingService {
    add(item: Item) {
        return new Promise((resolve, reject) => { resolve(item); });
    }
}

@Injectable()
export class SutEffect {

    _addItem(payload: { item: Item }) {
        return Observable.fromPromise(this._listService.add(payload.item));

    }

    @Effect() addItem$ = this._actions$
        .ofType<AddItem>(ADD_ITEM)
        .map(action => action.payload)
        .mergeMap<{ item: Item }, Observable<Item>>(payload => {
            return this._addItem(payload).map(item => new AddUpdateItemSuccess({
                item,
            }));
        });

    constructor(
        private _actions$: Actions,
        private _listService: ListingService) {

    }
}

Spec

import { cold, hot, getTestScheduler } from 'jasmine-marbles';
import { async, TestBed } from '@angular/core/testing';
import { Actions } from '@ngrx/effects';
import { Store, StoreModule } from '@ngrx/store';
import { getTestActions, TestActions } from 'app/tests/sut.helpers';

import { AddItem, AddUpdateItemSuccess, ListingService, SutEffect } from './sut.effect';
import { Observable } from 'rxjs/Observable';

import 'rxjs/add/observable/of';

describe('Effect Tests', () => {

    let store: Store<any>;
    let storeSpy: jasmine.Spy;

    beforeEach(async(() => {

        TestBed.configureTestingModule({
            imports: [
                StoreModule.forRoot({})
            ],
            providers: [
                SutEffect,
                {
                    provide: ListingService,
                    useValue: jasmine.createSpyObj('ListingService', ['add'])
                },
                {
                    provide: Actions,
                    useFactory: getTestActions
                }
            ]
        });

        store = TestBed.get(Store);
        storeSpy = spyOn(store, 'dispatch').and.callThrough();
        storeSpy = spyOn(store, 'select').and.callThrough();

    }));

    function setup() {
        return {
            effects: TestBed.get(SutEffect) as SutEffect,
            listingService: TestBed.get(ListingService) as jasmine.SpyObj<ListingService>,
            actions$: TestBed.get(Actions) as TestActions
        };
    }

    fdescribe('addItem$', () => {
        it('should return LoadItemsSuccess action for each item', async () => {

            const { effects, listingService, actions$ } = setup();
            const action = new AddItem({ item: 'test' });
            const completion = new AddUpdateItemSuccess({ item: 'test' });

            // mock this function which we can test later on, due to the promise issue
            spyOn(effects, '_addItem').and.returnValue(Observable.of('test'));

            actions$.stream = hot('-a|', { a: action });
            const expected = cold('-b|', { b: completion });

            expect(effects.addItem$).toBeObservable(expected);
            expect(effects._addItem).toHaveBeenCalled();

        });
    })

})

Helpers

import { Actions } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { empty } from 'rxjs/observable/empty';

export class TestActions extends Actions {
    constructor() {
        super(empty());
    }
    set stream(source: Observable<any>) {
        this.source = source;
    }
}

export function getTestActions() {
    return new TestActions();
}

All 8 comments

I tried with ReplaySubject (as per the example) and it seems to work.

However with cold / hot marbles it doesn't

try hot for everything. Colds don't "start" at the same time afaiu. I don't think it's a bug though. Also take a look at https://github.com/ReactiveX/rxjs/blob/master/doc/writing-marble-tests.md

@dinvlad I tried so. no luck. am I overseeing something here?

Yes, I went though that link too. all looks good to me :)

How do you mock the service? E.g. something like

spyOn(listService, 'add').and.returnValue(Promise.resolve(fakeItem()));

@dragGH102 Looks like this is an RxJS issue when using promises using marbles. https://stackoverflow.com/a/46313743/4148561

I did manage to do a bit of a hack which should work, however you will need to put a separate test the service is being called, unless you can update the service to return an observable instead of a promise.

Essentially what I did was extract the Observable.fromPromise call into its own "internal function" which we can mock to simulate a call to the service, then it looks from there.

This way you can test the internal function _addItem without using marbles.

Effect

import 'rxjs/add/observable/fromPromise';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';

import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';

export const ADD_ITEM = 'Add Item';
export const ADD_UPDATE_ITEM_SUCCESS = 'Add Item Success';

export class AddItem implements Action {
    type: string = ADD_ITEM;
    constructor(public payload: { item: any }) { }
}

export class AddUpdateItemSuccess implements Action {
    type: string = ADD_UPDATE_ITEM_SUCCESS;
    constructor(public payload: { item: any }) { }
}

export class Item {

}

export class ListingService {
    add(item: Item) {
        return new Promise((resolve, reject) => { resolve(item); });
    }
}

@Injectable()
export class SutEffect {

    _addItem(payload: { item: Item }) {
        return Observable.fromPromise(this._listService.add(payload.item));

    }

    @Effect() addItem$ = this._actions$
        .ofType<AddItem>(ADD_ITEM)
        .map(action => action.payload)
        .mergeMap<{ item: Item }, Observable<Item>>(payload => {
            return this._addItem(payload).map(item => new AddUpdateItemSuccess({
                item,
            }));
        });

    constructor(
        private _actions$: Actions,
        private _listService: ListingService) {

    }
}

Spec

import { cold, hot, getTestScheduler } from 'jasmine-marbles';
import { async, TestBed } from '@angular/core/testing';
import { Actions } from '@ngrx/effects';
import { Store, StoreModule } from '@ngrx/store';
import { getTestActions, TestActions } from 'app/tests/sut.helpers';

import { AddItem, AddUpdateItemSuccess, ListingService, SutEffect } from './sut.effect';
import { Observable } from 'rxjs/Observable';

import 'rxjs/add/observable/of';

describe('Effect Tests', () => {

    let store: Store<any>;
    let storeSpy: jasmine.Spy;

    beforeEach(async(() => {

        TestBed.configureTestingModule({
            imports: [
                StoreModule.forRoot({})
            ],
            providers: [
                SutEffect,
                {
                    provide: ListingService,
                    useValue: jasmine.createSpyObj('ListingService', ['add'])
                },
                {
                    provide: Actions,
                    useFactory: getTestActions
                }
            ]
        });

        store = TestBed.get(Store);
        storeSpy = spyOn(store, 'dispatch').and.callThrough();
        storeSpy = spyOn(store, 'select').and.callThrough();

    }));

    function setup() {
        return {
            effects: TestBed.get(SutEffect) as SutEffect,
            listingService: TestBed.get(ListingService) as jasmine.SpyObj<ListingService>,
            actions$: TestBed.get(Actions) as TestActions
        };
    }

    fdescribe('addItem$', () => {
        it('should return LoadItemsSuccess action for each item', async () => {

            const { effects, listingService, actions$ } = setup();
            const action = new AddItem({ item: 'test' });
            const completion = new AddUpdateItemSuccess({ item: 'test' });

            // mock this function which we can test later on, due to the promise issue
            spyOn(effects, '_addItem').and.returnValue(Observable.of('test'));

            actions$.stream = hot('-a|', { a: action });
            const expected = cold('-b|', { b: completion });

            expect(effects.addItem$).toBeObservable(expected);
            expect(effects._addItem).toHaveBeenCalled();

        });
    })

})

Helpers

import { Actions } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { empty } from 'rxjs/observable/empty';

export class TestActions extends Actions {
    constructor() {
        super(empty());
    }
    set stream(source: Observable<any>) {
        this.source = source;
    }
}

export function getTestActions() {
    return new TestActions();
}

Thanks all of the answers. yes, @phillipzada that looks like a good solution

   constructor() {
        super(empty());
    }
    set stream(source: Observable<any>) {
        this.source = source;
    }

issue is source and empty() both are deprecated :(

@frontr-uk Because is deprecated I have changed the approach using the _provideMockActions_, the action would be an let _actions$: Observable_;

   fdescribe('PizzaEffects', () => {
        let actions$: Observable<any>;;
        let service: Service;
        let effects: PizzaEffects;
        const data = givenPizzaData();

        beforeEach(() => {
            TestBed.configureTestingModule({
                imports: [ApolloTestingModule],
                providers: [
                    Service, 
                    PizzaEffects, 
                    Apollo, 
                    // { provide: Actions, useFactory: getActions }, remove
                    provideMockActions(() => actions$),
                ]
            });

            actions$ = TestBed.get(Actions);
            service = TestBed.get(Service);
            effects = TestBed.get(PizzaEffects);

            spyOn(service, 'loadData').and.returnValue(of(data));
        });

        describe('loadPizza', () => {
            it('should return a collection from LoadPizzaSuccess', () => {
                const action = new TriggerAction();
                const completion = new LoadPizzaSuccess(data);
                actions$ = hot('-a', { a: action });
                const expected = cold('-b', { b: completion });

                expect(effects.getPizzaEffect$).toBeObservable(expected);
            });
        });
    });
Was this page helpful?
0 / 5 - 0 ratings

Related issues

oxiumio picture oxiumio  路  3Comments

ghost picture ghost  路  3Comments

alvipeo picture alvipeo  路  3Comments

hccampos picture hccampos  路  3Comments

NathanWalker picture NathanWalker  路  3Comments