diff --git a/modules/store/spec/selector.spec.ts b/modules/store/spec/selector.spec.ts index cc0c176abb..93bd2afa52 100644 --- a/modules/store/spec/selector.spec.ts +++ b/modules/store/spec/selector.spec.ts @@ -47,6 +47,20 @@ describe('Selectors', () => { expect(projectFn).toHaveBeenCalledWith(countOne, countTwo); }); + it('should allow an override of the selector return', () => { + const projectFn = jasmine.createSpy('projectionFn').and.returnValue(2); + + const selector = createSelector(incrementOne, incrementTwo, projectFn); + + expect(selector.projector()).toBe(2); + + selector.setResult(5); + + const result2 = selector({}); + + expect(result2).toBe(5); + }); + it('should be possible to test a projector fn independent from the selectors it is composed of', () => { const projectFn = jasmine.createSpy('projectionFn'); const selector = createSelector(incrementOne, incrementTwo, projectFn); diff --git a/modules/store/spec/store.spec.ts b/modules/store/spec/store.spec.ts index 18d91df57b..11c09fe2bf 100644 --- a/modules/store/spec/store.spec.ts +++ b/modules/store/spec/store.spec.ts @@ -26,6 +26,7 @@ import Spy = jasmine.Spy; import any = jasmine.any; import { skip, take } from 'rxjs/operators'; import { MockStore, provideMockStore } from '../testing'; +import { createSelector } from '../src'; interface TestAppSchema { counter1: number; @@ -448,10 +449,9 @@ describe('ngRx Store', () => { describe('Mock Store', () => { let mockStore: MockStore; + const initialState = { counter1: 0, counter2: 1 }; beforeEach(() => { - const initialState = { counter1: 0, counter2: 1 }; - TestBed.configureTestingModule({ providers: [provideMockStore({ initialState })], }); @@ -482,6 +482,78 @@ describe('ngRx Store', () => { .subscribe(scannedAction => expect(scannedAction).toEqual(action)); mockStore.dispatch(action); }); + + it('should allow tracing dispatched actions', () => { + const action = { type: INCREMENT }; + mockStore.scannedActions$ + .pipe(skip(1)) + .subscribe(scannedAction => expect(scannedAction).toEqual(action)); + mockStore.dispatch(action); + }); + + it('should allow mocking of store.select with string selector', () => { + const mockValue = 5; + + mockStore.overrideSelector('counter1', mockValue); + + mockStore + .select('counter1') + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should allow mocking of store.select with a memoized selector', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter1 + ); + + mockStore.overrideSelector(selector, mockValue); + + mockStore + .select(selector) + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should allow mocking of store.pipe(select()) with a memoized selector', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter2 + ); + + mockStore.overrideSelector(selector, mockValue); + + mockStore + .pipe(select(selector)) + .subscribe(result => expect(result).toBe(mockValue)); + }); + + it('should pass through unmocked selectors', () => { + const mockValue = 5; + const selector = createSelector( + () => initialState, + state => state.counter1 + ); + const selector2 = createSelector( + () => initialState, + state => state.counter2 + ); + const selector3 = createSelector( + selector, + selector2, + (sel1, sel2) => sel1 + sel2 + ); + + mockStore.overrideSelector(selector, mockValue); + + mockStore + .pipe(select(selector2)) + .subscribe(result => expect(result).toBe(1)); + mockStore + .pipe(select(selector3)) + .subscribe(result => expect(result).toBe(6)); + }); }); describe('Meta Reducers', () => { diff --git a/modules/store/src/selector.ts b/modules/store/src/selector.ts index 332d3d01d0..4e9572a970 100644 --- a/modules/store/src/selector.ts +++ b/modules/store/src/selector.ts @@ -2,7 +2,11 @@ import { Selector, SelectorWithProps } from './models'; export type AnyFn = (...args: any[]) => any; -export type MemoizedProjection = { memoized: AnyFn; reset: () => void }; +export type MemoizedProjection = { + memoized: AnyFn; + reset: () => void; + setResult: (result?: any) => void; +}; export type MemoizeFn = (t: AnyFn) => MemoizedProjection; @@ -12,12 +16,14 @@ export interface MemoizedSelector extends Selector { release(): void; projector: AnyFn; + setResult: (result?: Result) => void; } export interface MemoizedSelectorWithProps extends SelectorWithProps { release(): void; projector: AnyFn; + setResult: (result?: Result) => void; } export function isEqualCheck(a: any, b: any): boolean { @@ -52,14 +58,24 @@ export function defaultMemoize( let lastArguments: null | IArguments = null; // tslint:disable-next-line:no-any anything could be the result. let lastResult: any = null; + let overrideResult: any; function reset() { lastArguments = null; lastResult = null; + overrideResult = undefined; + } + + function setResult(result: any = undefined) { + overrideResult = result; } // tslint:disable-next-line:no-any anything could be the result. function memoized(): any { + if (overrideResult !== undefined) { + return overrideResult; + } + if (!lastArguments) { lastResult = projectionFn.apply(null, arguments); lastArguments = arguments; @@ -82,7 +98,7 @@ export function defaultMemoize( return newResult; } - return { memoized, reset }; + return { memoized, reset, setResult }; } export function createSelector( @@ -594,6 +610,9 @@ export function createSelectorFactory( return Object.assign(memoizedState.memoized, { release, projector: memoizedProjector.memoized, + setResult: defaultMemoize.prototype.override + ? memoizedState.setResult + : undefined, }); }; } diff --git a/modules/store/testing/src/mock_selector.ts b/modules/store/testing/src/mock_selector.ts new file mode 100644 index 0000000000..55ec369ed2 --- /dev/null +++ b/modules/store/testing/src/mock_selector.ts @@ -0,0 +1,11 @@ +import { MemoizedSelector, MemoizedSelectorWithProps } from '@ngrx/store'; + +export interface MockSelector + extends MemoizedSelector { + setResult: (result?: Result) => void; +} + +export interface MockSelectorWithProps + extends MemoizedSelectorWithProps { + setResult: (result?: Result) => void; +} diff --git a/modules/store/testing/src/mock_store.ts b/modules/store/testing/src/mock_store.ts index d311ad4e8c..baad1bb3a4 100644 --- a/modules/store/testing/src/mock_store.ts +++ b/modules/store/testing/src/mock_store.ts @@ -6,11 +6,18 @@ import { INITIAL_STATE, ReducerManager, Store, + createSelector, } from '@ngrx/store'; import { MockState } from './mock_state'; +import { MockSelector, MockSelectorWithProps } from './mock_selector'; @Injectable() export class MockStore extends Store { + static selectors = new Map< + string | MockSelector | MockSelectorWithProps, + any + >(); + public scannedActions$: Observable; constructor( @@ -20,6 +27,7 @@ export class MockStore extends Store { @Inject(INITIAL_STATE) private initialState: T ) { super(state$, actionsObserver, reducerManager); + this.resetSelectors(); this.state$.next(this.initialState); this.scannedActions$ = actionsObserver.asObservable(); } @@ -28,6 +36,58 @@ export class MockStore extends Store { this.state$.next(nextState); } + overrideSelector( + selector: string, + value: Result + ): MockSelector; + overrideSelector( + selector: MockSelector, + value: Result + ): MockSelector; + overrideSelector( + selector: MockSelectorWithProps, + value: Result + ): MockSelectorWithProps; + overrideSelector( + selector: + | string + | MockSelector + | MockSelectorWithProps, + value: any + ) { + MockStore.selectors.set(selector, value); + + if (typeof selector === 'string') { + const stringSelector = createSelector(() => {}, () => value); + + return stringSelector; + } + + selector.setResult(value); + + return selector; + } + + resetSelectors() { + MockStore.selectors.forEach((_, selector) => { + if (typeof selector !== 'string') { + selector.setResult(); + } + }); + + MockStore.selectors.clear(); + } + + select(selector: any) { + if (MockStore.selectors.has(selector)) { + return new BehaviorSubject( + MockStore.selectors.get(selector) + ).asObservable(); + } + + return super.select(selector); + } + addReducer() { /* noop */ } diff --git a/modules/store/testing/src/testing.ts b/modules/store/testing/src/testing.ts index a31f4eb4b9..1e9cb95510 100644 --- a/modules/store/testing/src/testing.ts +++ b/modules/store/testing/src/testing.ts @@ -6,6 +6,7 @@ import { ReducerManager, StateObservable, Store, + defaultMemoize, } from '@ngrx/store'; import { MockStore } from './mock_store'; import { MockReducerManager } from './mock_reducer_manager'; @@ -27,6 +28,9 @@ export function provideMockStore( ]; } +defaultMemoize.prototype.override = true; + export { MockReducerManager } from './mock_reducer_manager'; +export { MockSelector, MockSelectorWithProps } from './mock_selector'; export { MockState } from './mock_state'; export { MockStore } from './mock_store'; diff --git a/projects/example-app/src/app/auth/containers/__snapshots__/login-page.component.spec.ts.snap b/projects/example-app/src/app/auth/containers/__snapshots__/login-page.component.spec.ts.snap index 5df6e2ab81..8d1f541a16 100644 --- a/projects/example-app/src/app/auth/containers/__snapshots__/login-page.component.spec.ts.snap +++ b/projects/example-app/src/app/auth/containers/__snapshots__/login-page.component.spec.ts.snap @@ -4,7 +4,7 @@ exports[`Login Page should compile 1`] = ` { let fixture: ComponentFixture; - let store: Store; + let store: MockStore; let instance: LoginPageComponent; beforeEach(() => { TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, - StoreModule.forRoot({ - auth: combineReducers(fromAuth.reducers), - }), MatInputModule, MatCardModule, ReactiveFormsModule, ], declarations: [LoginPageComponent, LoginFormComponent], + providers: [provideMockStore()], }); fixture = TestBed.createComponent(LoginPageComponent); instance = fixture.componentInstance; store = TestBed.get(Store); + store.overrideSelector(fromAuth.getLoginPagePending, false); - spyOn(store, 'dispatch').and.callThrough(); + spyOn(store, 'dispatch'); }); /** diff --git a/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts b/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts index 4dd60b26cb..414c29d9fa 100644 --- a/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts +++ b/projects/example-app/src/app/auth/services/auth-guard.service.spec.ts @@ -2,29 +2,23 @@ import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { cold } from 'jasmine-marbles'; import { AuthGuard } from '@example-app/auth/services/auth-guard.service'; -import * as fromRoot from '@example-app/reducers'; import * as fromAuth from '@example-app/auth/reducers'; -import * as fromLoginPage from '@example-app/auth/reducers/login-page.reducer'; -import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { provideMockStore, MockStore, MockSelector } from '@ngrx/store/testing'; describe('Auth Guard', () => { let guard: AuthGuard; let store: MockStore; - const initialState = { - auth: { - status: { - user: null, - }, - }, - } as fromAuth.State; + let loggedIn: MockSelector; beforeEach(() => { TestBed.configureTestingModule({ - providers: [AuthGuard, provideMockStore({ initialState })], + providers: [AuthGuard, provideMockStore()], }); store = TestBed.get(Store); guard = TestBed.get(AuthGuard); + + loggedIn = store.overrideSelector(fromAuth.getLoggedIn, false); }); it('should return false if the user state is not logged in', () => { @@ -34,20 +28,10 @@ describe('Auth Guard', () => { }); it('should return true if the user state is logged in', () => { - store.setState({ - ...initialState, - auth: { - loginPage: {} as fromLoginPage.State, - status: { - user: { - name: 'John', - }, - }, - }, - }); - const expected = cold('(a|)', { a: true }); + loggedIn.setResult(true); + expect(guard.canActivate()).toBeObservable(expected); }); }); diff --git a/projects/example-app/src/app/books/containers/__snapshots__/collection-page.component.spec.ts.snap b/projects/example-app/src/app/books/containers/__snapshots__/collection-page.component.spec.ts.snap index 22768e5f28..67643bddce 100644 --- a/projects/example-app/src/app/books/containers/__snapshots__/collection-page.component.spec.ts.snap +++ b/projects/example-app/src/app/books/containers/__snapshots__/collection-page.component.spec.ts.snap @@ -3,7 +3,7 @@ exports[`Collection Page should compile 1`] = ` { let fixture: ComponentFixture; - let store: Store; + let store: MockStore; let instance: CollectionPageComponent; beforeEach(() => { TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, - StoreModule.forRoot({ - books: combineReducers(fromBooks.reducers), - }), MatCardModule, MatInputModule, RouterTestingModule, @@ -36,16 +34,19 @@ describe('Collection Page', () => { AddCommasPipe, EllipsisPipe, ], + providers: [provideMockStore()], }); fixture = TestBed.createComponent(CollectionPageComponent); instance = fixture.componentInstance; store = TestBed.get(Store); - spyOn(store, 'dispatch').and.callThrough(); + spyOn(store, 'dispatch'); }); it('should compile', () => { + store.overrideSelector(fromBooks.getBookCollection, []); + fixture.detectChanges(); expect(fixture).toMatchSnapshot(); diff --git a/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts index 126ccc4e99..91fc8777e3 100644 --- a/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/find-book-page.component.spec.ts @@ -5,7 +5,7 @@ import { MatInputModule, MatProgressSpinnerModule, } from '@angular/material'; -import { combineReducers, Store, StoreModule } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { BookSearchComponent } from '@example-app/books/components/book-search.component'; @@ -18,19 +18,17 @@ import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; import { FindBookPageComponent } from '@example-app/books/containers/find-book-page.component'; import { FindBookPageActions } from '@example-app/books/actions'; import * as fromBooks from '@example-app/books/reducers'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; describe('Find Book Page', () => { let fixture: ComponentFixture; - let store: Store; + let store: MockStore; let instance: FindBookPageComponent; beforeEach(() => { TestBed.configureTestingModule({ imports: [ NoopAnimationsModule, - StoreModule.forRoot({ - books: combineReducers(fromBooks.reducers), - }), RouterTestingModule, MatInputModule, MatCardModule, @@ -46,13 +44,19 @@ describe('Find Book Page', () => { AddCommasPipe, EllipsisPipe, ], + providers: [provideMockStore()], }); fixture = TestBed.createComponent(FindBookPageComponent); instance = fixture.componentInstance; store = TestBed.get(Store); - spyOn(store, 'dispatch').and.callThrough(); + spyOn(store, 'dispatch'); + + store.overrideSelector(fromBooks.getSearchQuery, null); + store.overrideSelector(fromBooks.getSearchResults, []); + store.overrideSelector(fromBooks.getSearchLoading, false); + store.overrideSelector(fromBooks.getSearchError, null); }); it('should compile', () => { diff --git a/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts index db23d0738f..9f9b068d23 100644 --- a/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/selected-book-page.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SelectedBookPageComponent } from '@example-app/books/containers/selected-book-page.component'; -import { combineReducers, Store, StoreModule } from '@ngrx/store'; +import { Store } from '@ngrx/store'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatCardModule } from '@angular/material'; @@ -10,34 +10,30 @@ import { BookDetailComponent } from '@example-app/books/components/book-detail.c import { Book, generateMockBook } from '@example-app/books/models/book'; import { BookAuthorsComponent } from '@example-app/books/components/book-authors.component'; import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; describe('Selected Book Page', () => { let fixture: ComponentFixture; - let store: Store; + let store: MockStore; let instance: SelectedBookPageComponent; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - StoreModule.forRoot({ - books: combineReducers(fromBooks.reducers), - }), - MatCardModule, - ], + imports: [NoopAnimationsModule, MatCardModule], declarations: [ SelectedBookPageComponent, BookDetailComponent, BookAuthorsComponent, AddCommasPipe, ], + providers: [provideMockStore()], }); fixture = TestBed.createComponent(SelectedBookPageComponent); instance = fixture.componentInstance; store = TestBed.get(Store); - spyOn(store, 'dispatch').and.callThrough(); + spyOn(store, 'dispatch'); }); it('should compile', () => { diff --git a/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts b/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts index 4bd5118347..fb70ae346c 100644 --- a/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts +++ b/projects/example-app/src/app/books/containers/view-book-page.component.spec.ts @@ -11,12 +11,13 @@ import { SelectedBookPageComponent } from '@example-app/books/containers/selecte import { BookDetailComponent } from '@example-app/books/components/book-detail.component'; import { BookAuthorsComponent } from '@example-app/books/components/book-authors.component'; import { AddCommasPipe } from '@example-app/shared/pipes/add-commas.pipe'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; describe('View Book Page', () => { - const params = new BehaviorSubject({}); let fixture: ComponentFixture; - let store: Store; + let store: MockStore; let instance: ViewBookPageComponent; + let route: ActivatedRoute; beforeEach(() => { TestBed.configureTestingModule({ @@ -24,16 +25,9 @@ describe('View Book Page', () => { providers: [ { provide: ActivatedRoute, - useValue: { params }, - }, - { - provide: Store, - useValue: { - select: jest.fn(), - next: jest.fn(), - pipe: jest.fn(), - }, + useValue: { params: new BehaviorSubject({}) }, }, + provideMockStore(), ], declarations: [ ViewBookPageComponent, @@ -47,6 +41,9 @@ describe('View Book Page', () => { fixture = TestBed.createComponent(ViewBookPageComponent); instance = fixture.componentInstance; store = TestBed.get(Store); + route = TestBed.get(ActivatedRoute); + + jest.spyOn(store, 'dispatch'); }); it('should compile', () => { @@ -57,10 +54,9 @@ describe('View Book Page', () => { it('should dispatch a book.Select action on init', () => { const action = new ViewBookPageActions.SelectBook('2'); - params.next({ id: '2' }); - fixture.detectChanges(); + (route.params as BehaviorSubject).next({ id: '2' }); - expect(store.next).toHaveBeenLastCalledWith(action); + expect(store.dispatch).toHaveBeenLastCalledWith(action); }); }); diff --git a/projects/example-app/src/app/books/containers/view-book-page.component.ts b/projects/example-app/src/app/books/containers/view-book-page.component.ts index 5d50407e94..9162581d16 100644 --- a/projects/example-app/src/app/books/containers/view-book-page.component.ts +++ b/projects/example-app/src/app/books/containers/view-book-page.component.ts @@ -30,7 +30,7 @@ export class ViewBookPageComponent implements OnDestroy { constructor(store: Store, route: ActivatedRoute) { this.actionsSubscription = route.params .pipe(map(params => new ViewBookPageActions.SelectBook(params.id))) - .subscribe(store); + .subscribe(action => store.dispatch(action)); } ngOnDestroy() { diff --git a/tsconfig.json b/tsconfig.json index 2422f9e5cf..0f2288fe2f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "@ngrx/store": ["./modules/store"], "@ngrx/store/testing": ["./modules/store/testing"], "@ngrx/store/schematics-core": ["./modules/store/schematics-core"], + "@ngrx/store/testing": ["./modules/store/testing"], "@ngrx/store-devtools": ["./modules/store-devtools"], "@ngrx/store-devtools/schematics-core": [ "./modules/store-devtools/schematics-core"