Testing ngrx store’s subscription with appState, action, and selector

之前我研究了一下使用SpyObject来测试NGRX,但这种方法有一个缺点:

You need to mock everything of the spyObject like pipe(), select(), etc. But actually, we may want to test with the real implementation of store, appState, action and selector.

Assuming we have

export const appReducers: ActionReducerMap<AppState, any> = {

    feature1: feature1Reducer,

    feature2: feature2Reducer

};



export class ChangeExpandedState implements Action {

  readonly type = Feature1ActionTypes.CHANGE_EXPANDED_STATE;  // action enum used in reducer,this is old, use https://ngrx.io/api/store/createReducer instead

  constructor(

    public payload: { section: string; subSection?: string; id?: string | null }

  ) {}

}



export function feature1Reducer (state = initialFeature1State, action: Feature1Actions): feature1State {

  switch (action.type) {

    case Feature1ActionTypes. Feature1_LIST_CHANGE:

      return {

        ...state,

        feature1List: action.payload.updatedFeature1List

      };

    ……

    case Feature1ActionTypes. CHANGE_EXPANDED_STATE:

                 const expandBuilder = state.expandState;

   // do something to flipflop based on action.payload.section

  return {

    ...state,

    expandState: expandBuilder

                };

   }

}

In test spec file’s async beforeEach:

TestBed.configureTestingModule({

            imports: [

                StoreModule.forRoot(appReducers),

             …]

         …..

}).compileComponents();

testStore = TestBed.get(Store);

测试用例:

it('should subscribe to someActionState change, () => {

    expect(component.isSomeActionEnabled).toBeFalsy();

    component.isActionSuccess = true; // showing the action response

    testStore.dispatch(new ChangeExpandedState({ section: "someAction" }));

   expect(component.someFeatureEnabled).toBeTruthy();

    expect(component.isActionSuccess).toBeFalsy(); // clear last time response

    testStore.dispatch(new ChangeExpandedState({ section: "someAction" }));

    expect(component.isSomeActionEnabled).toBeFalsy();

});

expression changed after checked


这是一个非常常见的错误,点击到html的78行(实现一个errorHandler去log error.stack的重要性), 可以看到可能出错的几个绑定。最后通过排除法确定是一个属性的绑定(只能通过排除法,这一行html有好几个绑定)

<app-add-account-holder [expandMode]=”clientExpandMode” [isNewHousehold]=”household.isNew” [advisor]=”advisorSelector?.selectedAdvisor” (advisorErrorChange) =”onAdvisorError($event)”></app-add-account-holder>

问题出在ts代码的一个observable的subcription里

        this.showAdvisor = false;
       // this.advisorSelector = null;
代码执行完后,View会进行Change Detection and Binding。于是showAdvisor = false的后果就来了:
由于html里有<div *ngIf=”showAdvisor”
ts代码里的AdvisorSelectorComponent整个就消失了,ViewChild的static:false的作用就是同步更新这种时隐时现,特别是初始时不存在的界面元素的。
  @ViewChild(“advisorSelector”, { static: false })
  advisorSelector?: AdvisorSelectorComponent;
但这个场景下,由于showAdvisor 的变化,属性advisorSelector也就变成了null. 但这个变化是发生在Change Detection and Binding之后的。
Angular本不需要尝试再次绑定[advisorSelector]的。这完全是为了提醒码农你的代码里有属性发生了二次更新,这是危险的因为用户看到的不再是最新的属性绑定结果。但这个出错仅显示在console log,并不会真的crash.

Using CreateSpyObject with NGRX store testing

1. in the whole describe() lambda function, define below handles that can be referenced in all tests.

// use SpyObj because we want to spy multiple methods of the object
const testStore = jasmine.createSpyObj('Store', ['select', 'dispatch', 'pipe']);
// used to mock receving dispatched action     
const actions$: ReplaySubject<any> = new ReplaySubject();
let fixture : ComponentFixture<CurrentComponent>;
let component: CurrentComponent;

2.  both sync and async version of beforeEach happens before It()

beforeEach(async(() => {
   ......
  TestBed.configureTestingModule({       declarations: [XComponent, YComponent, ......],       providers: [XService, YService, // normal injection         provideMockActions(() => actions$),
           {           provide: Store, useValue: testStore         },
            ]
    })
 .compileComponents(); //compileComponents is async method
}));

beforeEach(()=>{
    testStore.dispatch.and.returnValue(true);
    testStore.select.and.callFake(()=>Observable.of(false));
    testStore.pipe.and.callFake(()=>Observable.of(true));
   // the store just gave some random objects so component won't throw exception
    fixture = TestBed.createComponent(CurrentComponent);
    component = fixture.componentInstance;
});
3. the tests Itself
It('some test', ()=> {
  action$.next({type: '[Action Name] Action Description', payload: {      success: true}
  });  // mocking the component getting an action from the store.
  component.someField = true; // because we called faked "select" and "pipe" spy methods, so we have to manually do the job.
  fixture.detectChanges();
  const compiled = fixture.debugElement.nativeElement;
  expect(compile.querySelector(".elementId")).toBeTruthy();
  expect(testStore.dispatch).toHaveBeenCalledWith(new SomeAction(...));
});

the drawback of this method is “component.someField = true;”, this is NOT really testing the NGRX action pipe/select and subscription method content.

to fix that, “testStore.select.and.callFake(()=>Observable.of(false));” need to change to  fake something that will really fit and be passed to the subscription method. like

()=>Observable.of({the projection of appState});