store icon indicating copy to clipboard operation
store copied to clipboard

πŸš€[FEATURE]: How to mock @Select in ngxs when using a mock store

Open wouterv opened this issue 6 years ago β€’ 33 comments

I am using ngxs for state handling in angular, and I am trying to test our components as units, so preferably only with mock stores, states etc.

What we have in our component is something like:

export class SelectPlatformComponent {

  @Select(PlatformListState) platformList$: Observable<PlatformListStateModel>;

  constructor(private store: Store, private fb: FormBuilder) {
    this.createForm();
    this.selectPlatform();
  }

  createForm() {
    this.selectPlatformForm = this.fb.group({
      platform: null,
    });
  }

  selectPlatform() {
    const platformControl = this.selectPlatformForm.get('platform');
    platformControl.valueChanges.forEach(
      (value: Platform) => {
        console.log("select platform " + value);
        this.store.dispatch(new PlatformSelected(value));
      }
    );
  }

}

And our fixture setup looks like this, so we can check calls on the store:

describe('SelectPlatformComponent', () => {
  let component: SelectPlatformComponent;
  let fixture: ComponentFixture<SelectPlatformComponent>;
  let store: Store;

  beforeEach(async(() => {
    const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
    TestBed.configureTestingModule({
      imports: [ReactiveFormsModule],
      declarations: [SelectPlatformComponent],
      providers: [{provide: Store, useValue: storeSpy}]

    })
      .compileComponents();
    store = TestBed.get(Store);
  }));

But when we run this, we get the following error:

Error: SelectFactory not connected to store!
    at SelectPlatformComponent.createSelect (webpack:///./node_modules/@ngxs/store/fesm5/ngxs-store.js?:1123:23)
    at SelectPlatformComponent.get [as platformList$] (webpack:///./node_modules/@ngxs/store/fesm5/ngxs-store.js?:1150:89)
    at Object.eval [as updateDirectives] (ng:///DynamicTestModule/SelectPlatformComponent.ngfactory.js:78:87)
    at Object.debugUpdateDirectives [as updateDirectives] (webpack:///./node_modules/@angular/core/fesm5/core.js?:11028:21)
    at checkAndUpdateView (webpack:///./node_modules/@angular/core/fesm5/core.js?:10425:14)
    at callViewAction (webpack:///./node_modules/@angular/core/fesm5/core.js?:10666:21)
    at execComponentViewsAction (webpack:///./node_modules/@angular/core/fesm5/core.js?:10608:13)
    at checkAndUpdateView (webpack:///./node_modules/@angular/core/fesm5/core.js?:10431:5)
    at callWithDebugContext (webpack:///./node_modules/@angular/core/fesm5/core.js?:11318:25)
    at Object.debugCheckAndUpdateView [as checkAndUpdateView] (webpack:///./node_modules/@angular/core/fesm5/core.js?:10996:12)

I could enable the entire ngxs module for this, but then I would need to create services mocks to inject into state objects, which I do not like because I am then not testing the component in isolation anymore. I tried to create a mock SelectFactory, but it seems it is not exported from the module.

Is there a way to mock the SelectFactory, or inject some mocks into the platformList$ directly? Other suggestions?

PS: I also asked this on stackoverflow, but no real answer was given: https://stackoverflow.com/questions/51082002/how-to-mock-select-in-ngxs-when-using-a-mock-store

wouterv avatar Jul 06 '18 08:07 wouterv

Sorry but you HAVE to inject ngxs module for this to work :(

amcdnl avatar Jul 15 '18 19:07 amcdnl

@amcdnl We could look at making a testability Helper for this. It's one of the items on my list.

markwhitfeld avatar Jul 15 '18 20:07 markwhitfeld

@amcdnl @markwhitfeld I can see it cannot be done now, but I would think it would be a good addition to make the framework a bit more open for mocking, so that we dont have to mock all our services that are used withing the states. Can this maybe be re-opened and converted to a feature request?

wouterv avatar Jul 24 '18 06:07 wouterv

Love the simplicity & clarity of ngxs vs redux. However, totally agree should not have to inject real dependencies to test components. Haven't dug into source code but was able to 'hack' around it by redefining the property in the test. Object.defineProperty(component, 'selectPropertyName', { writable: true });

Where 'component' is the component under test and 'selectPropertyName' is the name of the property decorated with '@Select'.

Once redefined, can simply provide observable you control in test flow: component.selectPropertyName = of(myData);

Not the ideal solution but feels like spirit of what i'm testing remains unsullied.

BradleyHill avatar Jul 24 '18 13:07 BradleyHill

@BradleyHill thanks! you saved my day

wouterv avatar Aug 02 '18 07:08 wouterv

@BradleyHill, @wouterv where exactly in the testing flow do you redefine your component properties? Does not seem to work inside my beforeEach since the error occurs inside the createComponent method :/

beforeEach(() => {  
       fixture = TestBed.createComponent(RequestStartAllowanceComponent);
       component = fixture.componentInstance;  
       Object.defineProperty(component, 'prop$', { writable: true });  
       component.prop$ = of('value');  
       fixture.detectChanges();  
  });

regexer-github avatar Aug 17 '18 09:08 regexer-github

@regexer-github Your code is exactly how we test it and it works for us. Maybe you can share your error?

wouterv avatar Aug 17 '18 09:08 wouterv

As @wouterv says, we have same thing save the override is in the test and not the beforeEach but that should not matter. Error would help. Maybe it's misdirection to something else.

The only other thing to note is if you are binding to the @Select observable in the html with async pipe, you are good to go. However, if you are doing something more intricate in the code behind with a subscription, you need to re-establish the subscription as the subscription typically occurs in the constructor or the ngOnInit. By the time you access the component from the TestBed, the subscription has been established with the original observable. When you set the observable in your test, there is no subscription to new observable. Although it violates the 'do not change code to accommodate tests' mantra, you can easily remedy this by moving the subscription to a method that the constructor or ngOnInit invokes, You can then just manually invoke same method after setting your new observable to establish the subscription.

Example:

constructor() {
  this.subscribeToProp();
}

subscribeToProp() {
  prop$.subscribe(value =>{ /* whatever crazy antics you enjoy */ });
}

// Then in test land
beforeEach(() => {  
       fixture = TestBed.createComponent(RequestStartAllowanceComponent);
       component = fixture.componentInstance;  
       Object.defineProperty(component, 'prop$', { writable: true });  
       component.prop$ = of('value');
       component.subscribeToProp();
       fixture.detectChanges();  
  });

(Authoring code inline so possible errors) Ideally @Selectable more mock friendly (haven't dug into source yet) but this is easy work-around and, IMO, doesn't sully the integrity of the test. Hope this helps.

BradleyHill avatar Aug 18 '18 17:08 BradleyHill

Well, I somehow cannot get this to fail anymore... I assume ng test did recompile correctly :/
Thank you for the advice anyway.
The idea of moving the initialization of subscriptions to a separate method will definitely come in handy.

regexer-github avatar Aug 20 '18 08:08 regexer-github

I'm in a similar situation but the @Select is inside a State. Is it possible to use a similar approach starting from a State TestBed?

 TestBed.configureTestingModule({
        providers: [
         ...
        ],
        imports: [
            NgxsModule.forRoot([MyAwesomeState])
        ]
    }).compileComponents();

beyondsanity avatar Sep 20 '18 13:09 beyondsanity

Sorry, I just found a solution:

state = TestBed.get(MyAwesomeState);
Object.defineProperty(state, 'prop$', { writable: true });
state.prop$ = of('value);

beyondsanity avatar Sep 20 '18 14:09 beyondsanity

How would you do this when your component template references another component that contains a public @Select() Observable?

Would you just mock the component?

gforceg avatar Jan 22 '19 20:01 gforceg

It feels like I'm trading ngrx boiler plate for Angular services that sit between my components and ngxs in order to make my code testable.

gforceg avatar Jan 23 '19 22:01 gforceg

Hi everyone. We would like to add some test helpers for ngxs soon to make testing easier. Would any of you like to assist us in this effort? Discussion, ideas or code welcome!

markwhitfeld avatar Jan 24 '19 17:01 markwhitfeld

I found this way makes it easy to write unit tests for components without calling actions.

Firstly I write a plugin to ignore actions and store them in an array to later assert if that action was called:

import { Inject, Injectable, InjectionToken } from '@angular/core';
import { NgxsPlugin } from '@ngxs/store';
import { of } from 'rxjs';

export const NGXS_ACTIONS = new InjectionToken('NGXS_ACTIONS');

@Injectable()
export class NgxsTestPlugin implements NgxsPlugin {
  constructor(@Inject(NGXS_ACTIONS) private actions: any[]) { }

  handle(state, action, next) {
    console.log('Action called in test', action);
    this.actions.push(action);
    return of(state);
  }
}

Then I use it in my testing module:

  ...
  providers: [
    {
      provide: NGXS_PLUGINS,
      useClass: NgxsTestPlugin,
      multi: true
    },
    {
      provide: NGXS_ACTIONS,
      useValue: [],
    }
  ],
  ...

Now I can assert if actions were called in my tests without causing any side effects:

    const actions = TestBed.get(NGXS_ACTIONS);
    expect(getActionTypeFromInstance(actions[1])).toEqual('Logout');

There is no need to write mock for Selectors this way and we can use store.reset to skip ahead in unit tests.

KurtGokhan avatar Jan 24 '19 18:01 KurtGokhan

We have espoused the philosophy that components are dumb as possible. They render values from state and communicate user actions to state via actions. That's it. Logic in components is bad.

We write tests against rendering by seeding different states to our bound @Select variables via the hack above (the only ngxs work-around we've needed). Make property writable, set it to observable of desired value, fixture detect changes, assert whatever you expect to be rendered.

To test the user interaction, we simply spy on store dispatch in setup: storeSpy = spyOn(TestBed.get(Store), 'dispatch').and.returnValue(of(true)); and then asset on combinations of jest (or jasmine): toHaveBeenCalled, not.toHaveBeenCalled, toHaveBeenCalledTimes, toHaveBeenCalledWith e.g. expect(storeSpy).toHaveBeenCalledWith(expect.objectContaining({ propOfInterest: someValue }));

Ridiculously easy. Has gotten us a long ways.

BradleyHill avatar Jan 24 '19 19:01 BradleyHill

@markwhitfeld Love the product. Ngrx sans all the ceremony. With redux pattern implementations, found great developers authored meh change and meh developers authored horrible code. This package strips down to the essence.

We haven't had many issues save the hack mentioned above but if have ideas and i can see value in them, may be able to throw some resources at them to solve them on our project and then push the code back to the github repo. Ngxs has been wonderful addition to project so would love to give back if can find use cases. I'm hug test advocate also so anything to help promote or simplify testing, no sell necessary.

BradleyHill avatar Jan 24 '19 19:01 BradleyHill

@gforceg As for your first question, typically mock out second component. It's a whole "unit" thing. You should only care about the primary component, not any transitive effects down the line. Law Of Demeter is testing.

Second question is little more nebulous. Not sure you are making a concession for testability. However, ngxs (or ngrx or any cqrs solution) is no panacea. You can accomplish your goal with ngxs, rolled services, or raw http client calls for that matter). Just a decision need to make depending on many factors.

BradleyHill avatar Jan 24 '19 19:01 BradleyHill

Hi Bradley, I did exactly what you suggested. However it keeps telling me that my β€œprop$” is undefined. Any hints? Thanks.

theyCallMeJay avatar Apr 11 '19 05:04 theyCallMeJay

@theyCallMeJay we are now working on a new package @ngxs/store/testing

splincode avatar Apr 11 '19 06:04 splincode

@theyCallMeJay we are now working on a new package @ngxs/store/testing

@splincode That is amazing. May I ask the estimate of your release date for that package? I'm really scratching my head at this moment since my company is asking for unit testing coverage for the application but I can't provide none atm.

theyCallMeJay avatar Apr 11 '19 11:04 theyCallMeJay

@wouterv is it possible to share your piece of unit testing code that worked for you? Thanks

theyCallMeJay avatar Apr 11 '19 12:04 theyCallMeJay

It was stated above, but for clarity, here goes:

describe('SelectPlatformsourceComponent', () => {
  let component: SelectPlatformsourceComponent;
  let fixture: ComponentFixture<SelectPlatformsourceComponent>;
  let store: Store;
  let sourceProducer: BehaviorSubject<string>;
  let storeSpy;

  beforeEach(async(() => {
    storeSpy = jasmine.createSpyObj<Store>(['dispatch', 'selectSnapshot']  as any);
    TestBed.configureTestingModule({
      imports: [NgbModule, ReactiveFormsModule],
      declarations: [SelectPlatformsourceComponent],
      providers: [{provide: Store, useValue: storeSpy}]
    }).compileComponents();
    store = TestBed.get(Store);
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(SelectPlatformsourceComponent);
    component = fixture.componentInstance;
    Object.defineProperty(component, 'platformSourceNamesList$', {writable: true});
    component.platformSourceNamesList$ = of(['foo', 'bar']);
    Object.defineProperty(component, 'source$', {writable: true});
    sourceProducer = new BehaviorSubject<string>(null);
    component.source$ = sourceProducer;
    fixture.detectChanges();
  });

wouterv avatar Apr 11 '19 12:04 wouterv

@wouterv, thanks! that still did not work for me though i did the same thing. Do you mind sharing what is inside your testbed configure? Thanks.

theyCallMeJay avatar Apr 11 '19 12:04 theyCallMeJay

@theyCallMeJay I put in all we have.

wouterv avatar Apr 11 '19 13:04 wouterv

@wouterv , my bad. I somehow solved it by removing store from provide. Here is my snippet in case others have same issues as well.

beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ DashboardComponent, ], imports: [ NgxPaginationModule, NgxsModule.forRoot([SessionState, InitiativeState]), NgxsReduxDevtoolsPluginModule, NgxsLoggerPluginModule, RouterTestingModule, SharedModule ], providers: [ /* {provide: Store, useValue: storeSpy}, */ {provide: SessionService, useValue: mockSessionService}, {provide: ApiService, useValue: apiService} ] }).compileComponents();

theyCallMeJay avatar Apr 11 '19 13:04 theyCallMeJay

@theyCallMeJay NgxsModule is debatable, but you really shouldn't (have a reason to) have NgxsReduxDevtoolsPluginModule or NgxsLoggerPluginModule as a dependency. Those are not meant to be used in production and should not be in your dependencies except when running in dev (not test) environment.

philly-vanilly avatar Apr 21 '19 09:04 philly-vanilly

This problem is now considered here: ngxs-labs/ngxs-testing#3

splincode avatar May 29 '19 20:05 splincode

Hello there!

At work, we also need to mock the selects while testing our components. I've seen in a recent commit that @Select decorator will be considered deprecated in future releases so I am not going to focus on this here. (Because it won't work with this approach)

I've used NGXS in the past and was able to convince my team to use it over NGRX. The main argument was that the programming style of NGXS is more in line with Angular. I've seen the internal NgxsTestBed and I'm not a fan mainly for the same reason. It's not the Angular way.

Inspired by the HttpTestingModule, I have quickly created an NgxsTestingModule. I would like some feedback.

import { Inject, Injectable, InjectionToken, NgModule } from '@angular/core';
import {NgxsModule, Store} from '@ngxs/store'
import { concat, Observable, ReplaySubject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
 
const ORIGINAL_STORE = new InjectionToken<Store>('OriginalStore')
 
@Injectable()
class MockedSelectors {
    private mockedSelectors: { key: any; value: ReplaySubject<any> }[] = []
    
    getMockedSelector(key: any) {
        let match = this.mockedSelectors.find( s => s.key === key)
 
        if(!match) {
            match = {key, value: new ReplaySubject<any>(1)}
            this.mockedSelectors.push(match)
        }
 
        return match.value
    }
}
 
@Injectable()
class StoreInterceptor {
 
    constructor(
        private mockedSelectors: MockedSelectors,
        @Inject(ORIGINAL_STORE) private store: Store
    ){}
 
    // interceptors
 
    dispatch(actionOrActions: any | any[]): Observable<any> {
        return this.store.dispatch(actionOrActions)
    }
 
    select(selector: any): Observable<any> {
 
        const mockedSelector = this.mockedSelectors.getMockedSelector(selector)
 
        return concat(
            this.store.select(selector).pipe(takeUntil(mockedSelector)),
            mockedSelector
        )
    }
 
    selectOnce(selector: any): Observable<any> {
        return this.store.selectOnce(selector)
    }
 
    selectSnapshot(selector: any): any {
        return this.store.selectSnapshot(selector)
    }
 
    subscribe(fn?: (value: any) => void): Subscription {
        return this.store.subscribe(fn)
    }
 
    snapshot(): any {
        return this.store.snapshot()
    }
 
    reset(state: any) {
        this.store.reset(state)
    }
 
}
 
@Injectable()
export class NgxsTestingController {
 
    constructor(private mockedSelector: MockedSelectors) {}
 
    mockSelector(selector: any) {
        return this.mockedSelector.getMockedSelector(selector)
    }
}
 
@NgModule()
export class NgxsTestingModule {
 
    static forRoot(...args) {
        const ngxsModule = NgxsModule.forRoot(...args)
 
        return {
            ...ngxsModule,
            providers: [
                {provide: Store, useClass: StoreInterceptor},
                {provide: ORIGINAL_STORE, useClass: Store},
                MockedSelectors,
                NgxsTestingController,
                ...ngxsModule.providers.filter(p => p !== Store)
            ]
        }
 
    }
 
}

Usage:

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Store } from '@ngxs/store';
import { SessionState } from '@sdum/core/store';
import {NgxsTestingModule, NgxsTestingController} from './ngxs-testing.module'
 

@Component({
    selector: 'test-component',
    template: ''
})
export class TestComponent  {

    authorities$ = this.store.select(SessionState.authorities)
    constructor(private store: Store) { }
    
}

describe('NgxsTestingModule', () => {
 
    let fixture: ComponentFixture<TestComponent>
    let component: TestComponent
    let ngxsTestingController: NgxsTestingController
  
    
    beforeEach(async () => {
        await TestBed.configureTestingModule({
          imports: [
            NgxsTestingModule.forRoot(),
          ],
          declarations: [
            TestComponent
          ]
    
        }).compileComponents()
        
        ngxsTestingController = TestBed.inject(NgxsTestingController)
        fixture = TestBed.createComponent(TestComponent)
        component = fixture.componentInstance
    })
 
    describe('SessionState.authorities', () => {
        it('should be mocked', async () => {
            ngxsTestingController.mockSelector(SessionState.authorities).next(['READ', 'WRITE', 'DELETE'])
            expect(await component.authorities$.toPromise()).toEqual(['READ', 'WRITE', 'DELETE'])
        });
    });
    
})

FortinFred avatar Oct 23 '22 20:10 FortinFred

Hello there!

At work, we also need to mock the selects while testing our components. I've seen in a recent commit that @select decorator will be considered deprecated in future releases so I am not going to focus on this here. (Because it won't work with this approach)

I've used NGXS in the past and was able to convince my team to use it over NGRX. The main argument was that the programming style of NGXS is more in line with Angular. I've seen the internal NgxsTestBed and I'm not a fan mainly for the same reason. It's not the Angular way.

Inspired by the HttpTestingModule, I have quickly created an NgxsTestingModule. I would like some feedback.

import { Inject, Injectable, InjectionToken, NgModule } from '@angular/core';
import {NgxsModule, Store} from '@ngxs/store'
import { concat, Observable, ReplaySubject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
 
const ORIGINAL_STORE = new InjectionToken<Store>('OriginalStore')
 
@Injectable()
class MockedSelectors {
    private mockedSelectors: { key: any; value: ReplaySubject<any> }[] = []
    
    getMockedSelector(key: any) {
        let match = this.mockedSelectors.find( s => s.key === key)
 
        if(!match) {
            match = {key, value: new ReplaySubject<any>(1)}
            this.mockedSelectors.push(match)
        }
 
        return match.value
    }
}
 
@Injectable()
class StoreInterceptor {
 
    constructor(
        private mockedSelectors: MockedSelectors,
        @Inject(ORIGINAL_STORE) private store: Store
    ){}
 
    // interceptors
 
    dispatch(actionOrActions: any | any[]): Observable<any> {
        return this.store.dispatch(actionOrActions)
    }
 
    select(selector: any): Observable<any> {
 
        const mockedSelector = this.mockedSelectors.getMockedSelector(selector)
 
        return concat(
            this.store.select(selector).pipe(takeUntil(mockedSelector)),
            mockedSelector
        )
    }
 
    selectOnce(selector: any): Observable<any> {
        return this.store.selectOnce(selector)
    }
 
    selectSnapshot(selector: any): any {
        return this.store.selectSnapshot(selector)
    }
 
    subscribe(fn?: (value: any) => void): Subscription {
        return this.store.subscribe(fn)
    }
 
    snapshot(): any {
        return this.store.snapshot()
    }
 
    reset(state: any) {
        this.store.reset(state)
    }
 
}
 
@Injectable()
export class NgxsTestingController {
 
    constructor(private mockedSelector: MockedSelectors) {}
 
    mockSelector(selector: any) {
        return this.mockedSelector.getMockedSelector(selector)
    }
}
 
@NgModule()
export class NgxsTestingModule {
 
    static forRoot(...args) {
        const ngxsModule = NgxsModule.forRoot(...args)
 
        return {
            ...ngxsModule,
            providers: [
                {provide: Store, useClass: StoreInterceptor},
                {provide: ORIGINAL_STORE, useClass: Store},
                MockedSelectors,
                NgxsTestingController,
                ...ngxsModule.providers.filter(p => p !== Store)
            ]
        }
 
    }
 
}

Usage:

import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Store } from '@ngxs/store';
import { SessionState } from '@sdum/core/store';
import {NgxsTestingModule, NgxsTestingController} from './ngxs-testing.module'
 

@Component({
    selector: 'test-component',
    template: ''
})
export class TestComponent  {

    authorities$ = this.store.select(SessionState.authorities)
    constructor(private store: Store) { }
    
}

describe('NgxsTestingModule', () => {
 
    let fixture: ComponentFixture<TestComponent>
    let component: TestComponent
    let ngxsTestingController: NgxsTestingController
  
    
    beforeEach(async () => {
        await TestBed.configureTestingModule({
          imports: [
            NgxsTestingModule.forRoot(),
          ],
          declarations: [
            TestComponent
          ]
    
        }).compileComponents()
        
        ngxsTestingController = TestBed.inject(NgxsTestingController)
        fixture = TestBed.createComponent(TestComponent)
        component = fixture.componentInstance
    })
 
    describe('SessionState.authorities', () => {
        it('should be mocked', async () => {
            ngxsTestingController.mockSelector(SessionState.authorities).next(['READ', 'WRITE', 'DELETE'])
            expect(await component.authorities$.toPromise()).toEqual(['READ', 'WRITE', 'DELETE'])
        });
    });
    
})

Hello, where did you see that @select in future versions will be deprecated? I would like to read more about it, as I am starting a new project and using ngxs to store.

weslleysilva avatar Jan 05 '23 20:01 weslleysilva