ngxs / store

πŸš€ NGXS - State Management for Angular
http://ngxs.io
MIT License
3.53k stars 399 forks source link

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

Open wouterv opened 6 years ago

wouterv commented 6 years ago

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

amcdnl commented 6 years ago

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

markwhitfeld commented 6 years ago

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

wouterv commented 6 years ago

@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?

BradleyHill commented 6 years ago

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.

wouterv commented 6 years ago

@BradleyHill thanks! you saved my day

regexer-github commented 6 years ago

@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();  
  });
wouterv commented 6 years ago

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

BradleyHill commented 6 years ago

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.

regexer-github commented 6 years ago

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.

beyondsanity commented 5 years ago

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 commented 5 years ago

Sorry, I just found a solution:

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

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 commented 5 years ago

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.

markwhitfeld commented 5 years ago

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!

KurtGokhan commented 5 years ago

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.

BradleyHill commented 5 years ago

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 commented 5 years ago

@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 commented 5 years ago

@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.

theyCallMeJay commented 5 years ago

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

splincode commented 5 years ago

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

theyCallMeJay commented 5 years ago

@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 commented 5 years ago

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

wouterv commented 5 years ago

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();
  });
theyCallMeJay commented 5 years ago

@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.

wouterv commented 5 years ago

@theyCallMeJay I put in all we have.

theyCallMeJay commented 5 years ago

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

philly-vanilly commented 5 years ago

@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.

splincode commented 5 years ago

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

FortinFred commented 1 year ago

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'])
        });
    });

})
weslleysilva commented 1 year ago

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.

koraxos commented 1 year ago

Is there any news or plan to upgrade the tools in the library to mock ngxs properly, For me it's really bad, i'm in a team with lot of boilerplate in our .spec ( we have over 8000 unit test). Most of the time where we need Ngxs, the store has been injected and it really is memory and time consuming in ours unit tests. The fact is that it's what is recommended in the documentation. but there is no tools to really mock the store or mock functions like dispatch.

If you take NgRx for example they have done somehting good , https://ngrx.io/guide/store/testing , Another example is the way the HttpTestingModule from Angular works.

It's really a major downgrade for ngxs, Is there any discussion somewhere about the specification of the tooling we need ? I would like to contribute on this subject. I think it would improve greatly Ngxs usability in unit testing.

sebimarkgraf commented 10 months ago

@koraxos Unsure if you already tried the approach from @FortinFred but it works with the exception of one case wonderfully: When using dynamic selectors, they selector is given as memoized function without any further metadata. Therefore, it is not possible to give a mocked selector for the case.

Maybe, you have another idea to solve this issue. If that's the case, the approach could be developed into a ngxs testing package that provides that functionality.

This could be an discussion point for the design of the metadata API of version 4.0 as well, but as this issue ranks as low in priority, I am unsure how much this is going to influence the decisions by the NGXS core team.

sebimarkgraf commented 10 months ago

Found a solution for anyone interested: We simply use a decorator to patch the selectorName and arguments on the memoized function to have them available in testing. We did not evaluate the performance impace yet, but as long as you are not excessively creating selectors this should be acceptable.

export function TestSelectorName(name?: string) {
  return function innerFunction(target, propertyKey: string, descriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any[]) {
      const selector = originalMethod.apply(this, args);
      (selector as any).selectorName = name || propertyKey;
      (selector as any).selectorArgs = args;
      return selector;
    };
  };
}

We just annotate our static methods with @TestSelectorName() and use the method name as default name.

For the tests you can then use the same selector with your specified arguments and FortinFreds approach:

ngxsTestingController.mockSelector(CustomState.customSelector('example-uuid')).next({property: a})

This required small adjustments to the implementation of FortinFreds mock store, but this is manageable. I hope this could be more streamlined with Ngxs 4.0 but this fullfills our requirements for the moment.