Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to correctly test effects in ngrx 4 #498

Closed
dragGH102 opened this issue Oct 17, 2017 · 8 comments
Closed

How to correctly test effects in ngrx 4 #498

dragGH102 opened this issue Oct 17, 2017 · 8 comments

Comments

@dragGH102
Copy link

dragGH102 commented Oct 17, 2017

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)

@dragGH102
Copy link
Author

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

However with cold / hot marbles it doesn't

@dragGH102 dragGH102 changed the title How to correctly How to correctly test effects in ngrx 4 Oct 17, 2017
@dinvlad
Copy link
Contributor

dinvlad commented Oct 17, 2017

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

@dragGH102
Copy link
Author

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

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

@dinvlad
Copy link
Contributor

dinvlad commented Oct 17, 2017

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

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

@phillipzada
Copy link
Contributor

@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();
}

@dragGH102
Copy link
Author

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

@frontr-uk
Copy link

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

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

@Tabares
Copy link

Tabares commented Jul 26, 2019

@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);
            });
        });
    });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants