Skip to content

Commit

Permalink
feat(store): add API to mock selectors (#1688)
Browse files Browse the repository at this point in the history
Closes #1504
  • Loading branch information
brandonroberts authored Apr 4, 2019
1 parent 3b9b890 commit 2a9b067
Show file tree
Hide file tree
Showing 17 changed files with 251 additions and 106 deletions.
14 changes: 14 additions & 0 deletions modules/store/spec/selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
104 changes: 102 additions & 2 deletions modules/store/spec/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/selector';

interface TestAppSchema {
counter1: number;
Expand Down Expand Up @@ -448,10 +449,9 @@ describe('ngRx Store', () => {

describe('Mock Store', () => {
let mockStore: MockStore<TestAppSchema>;
const initialState = { counter1: 0, counter2: 1 };

beforeEach(() => {
const initialState = { counter1: 0, counter2: 1 };

TestBed.configureTestingModule({
providers: [provideMockStore({ initialState })],
});
Expand Down Expand Up @@ -482,6 +482,106 @@ describe('ngRx Store', () => {
.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));
});

it('should allow you reset mocked 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
.pipe(select(selector3))
.subscribe(result => expect(result).toBe(1));

mockStore.overrideSelector(selector, mockValue);
mockStore.overrideSelector(selector2, mockValue);
selector3.release();

mockStore
.pipe(select(selector3))
.subscribe(result => expect(result).toBe(10));

mockStore.resetSelectors();
selector3.release();

mockStore
.pipe(select(selector3))
.subscribe(result => expect(result).toBe(1));
});
});

describe('Meta Reducers', () => {
Expand Down
20 changes: 18 additions & 2 deletions modules/store/src/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -12,12 +16,14 @@ export interface MemoizedSelector<State, Result>
extends Selector<State, Result> {
release(): void;
projector: AnyFn;
setResult: (result?: Result) => void;
}

export interface MemoizedSelectorWithProps<State, Props, Result>
extends SelectorWithProps<State, Props, Result> {
release(): void;
projector: AnyFn;
setResult: (result?: Result) => void;
}

export function isEqualCheck(a: any, b: any): boolean {
Expand Down Expand Up @@ -52,14 +58,23 @@ 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;
}

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;
Expand All @@ -82,7 +97,7 @@ export function defaultMemoize(
return newResult;
}

return { memoized, reset };
return { memoized, reset, setResult };
}

export function createSelector<State, S1, Result>(
Expand Down Expand Up @@ -563,6 +578,7 @@ export function createSelectorFactory(
return Object.assign(memoizedState.memoized, {
release,
projector: memoizedProjector.memoized,
setResult: memoizedState.setResult,
});
};
}
Expand Down
64 changes: 64 additions & 0 deletions modules/store/testing/src/mock_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,21 @@ import {
INITIAL_STATE,
ReducerManager,
Store,
createSelector,
MemoizedSelectorWithProps,
MemoizedSelector,
} from '@ngrx/store';
import { MockState } from './mock_state';

@Injectable()
export class MockStore<T> extends Store<T> {
static selectors = new Map<
| string
| MemoizedSelector<any, any>
| MemoizedSelectorWithProps<any, any, any>,
any
>();

public scannedActions$: Observable<Action>;

constructor(
Expand All @@ -20,6 +30,7 @@ export class MockStore<T> extends Store<T> {
@Inject(INITIAL_STATE) private initialState: T
) {
super(state$, actionsObserver, reducerManager);
this.resetSelectors();
this.state$.next(this.initialState);
this.scannedActions$ = actionsObserver.asObservable();
}
Expand All @@ -28,6 +39,59 @@ export class MockStore<T> extends Store<T> {
this.state$.next(nextState);
}

overrideSelector<T, Result>(
selector: string,
value: Result
): MemoizedSelector<string, Result>;
overrideSelector<T, Result>(
selector: MemoizedSelector<T, Result>,
value: Result
): MemoizedSelector<T, Result>;
overrideSelector<T, Result>(
selector: MemoizedSelectorWithProps<T, any, Result>,
value: Result
): MemoizedSelectorWithProps<T, any, Result>;
overrideSelector<T, Result>(
selector:
| string
| MemoizedSelector<any, any>
| MemoizedSelectorWithProps<any, any, any>,
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.release();
selector.setResult();
}
});

MockStore.selectors.clear();
}

select(selector: any) {
if (MockStore.selectors.has(selector)) {
return new BehaviorSubject<any>(
MockStore.selectors.get(selector)
).asObservable();
}

return super.select(selector);
}

addReducer() {
/* noop */
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { StoreModule, Store, combineReducers } from '@ngrx/store';
import { LoginFormComponent } from '@example-app/auth/components/login-form.component';
import { ReactiveFormsModule } from '@angular/forms';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`Login Page should compile 1`] = `
<bc-login-page
error$={[Function Store]}
pending$={[Function Store]}
store={[Function Store]}
store={[Function MockStore]}
>
<bc-login-form
ng-reflect-pending="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,36 @@ import { TestBed, ComponentFixture } from '@angular/core/testing';
import { MatInputModule, MatCardModule } from '@angular/material';
import { ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { StoreModule, Store, combineReducers } from '@ngrx/store';
import { Store } from '@ngrx/store';
import { LoginPageComponent } from '@example-app/auth/containers/login-page.component';
import { LoginFormComponent } from '@example-app/auth/components/login-form.component';
import * as fromAuth from '@example-app/auth/reducers';
import { LoginPageActions } from '@example-app/auth/actions';
import { provideMockStore, MockStore } from '@ngrx/store/testing';

describe('Login Page', () => {
let fixture: ComponentFixture<LoginPageComponent>;
let store: Store<fromAuth.State>;
let store: MockStore<fromAuth.State>;
let instance: LoginPageComponent;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
StoreModule.forRoot(
{
auth: combineReducers(fromAuth.reducers),
},
{
runtimeChecks: {
strictImmutability: true,
},
}
),
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');
});

/**
Expand Down
Loading

0 comments on commit 2a9b067

Please sign in to comment.