Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs: add section to Store testing guide on using mock selectors #1797

Merged
merged 15 commits into from
May 5, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions projects/example-app/src/app/books/effects/collection.effects.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType, createEffect } from '@ngrx/effects';
import { defer, of } from 'rxjs';
import { catchError, map, mergeMap, switchMap } from 'rxjs/operators';
import {
catchError,
map,
mergeMap,
switchMap,
withLatestFrom,
tap,
} from 'rxjs/operators';
import { Book } from '@example-app/books/models/book';
import {
CollectionApiActions,
CollectionPageActions,
SelectedBookPageActions,
} from '@example-app/books/actions';
import { BookStorageService } from '@example-app/core/services';
import * as fromBooks from '@example-app/books/reducers';
import { select, Store } from '@ngrx/store';

@Injectable()
export class CollectionEffects {
/**
Expand Down Expand Up @@ -66,8 +76,25 @@ export class CollectionEffects {
)
);

addBookToCollectionSuccess$ = createEffect(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove?

() =>
this.actions$.pipe(
ofType(CollectionApiActions.addBookSuccess),
withLatestFrom(this.store.pipe(select(fromBooks.getCollectionBookIds))),
tap(([, bookCollection]) => {
if (bookCollection.length === 1) {
window.alert('Congrats on adding your first book!');
} else {
window.alert('You have added book number ' + bookCollection.length);
}
})
),
{ dispatch: false }
);

constructor(
private actions$: Actions,
private storageService: BookStorageService
private storageService: BookStorageService,
private store: Store<fromBooks.State>
) {}
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createAction } from '@ngrx/store';

export const login = createAction('[Auth] Login');
export const logout = createAction('[Auth] Logout');
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import * as AuthActions from './auth.actions';

export { AuthActions };
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { Store, MemoizedSelector } from '@ngrx/store';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { cold } from 'jasmine-marbles';
import { AuthGuard } from './auth-guard.service';
import * as fromAuth from './reducers';

describe('Auth Guard', () => {
let guard: AuthGuard;
let store: MockStore<fromAuth.State>;
let loggedIn: MemoizedSelector<fromAuth.State, boolean>;

beforeEach(() => {
TestBed.configureTestingModule({
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', () => {
const expected = cold('(a|)', { a: false });

expect(guard.canActivate()).toBeObservable(expected);
});

it('should return true if the user state is logged in', () => {
const expected = cold('(a|)', { a: true });

loggedIn.setResult(true);

expect(guard.canActivate()).toBeObservable(expected);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { Store, select } from '@ngrx/store';
import { take } from 'rxjs/operators';
import * as fromAuth from './reducers';

@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private store: Store<fromAuth.State>) {}

canActivate() {
return this.store.pipe(
select(fromAuth.getLoggedIn),
take(1)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createReducer, on } from '@ngrx/store';
import { AuthActions } from '../actions';

export interface State {
loggedIn: boolean;
}

export const initialState: State = {
loggedIn: false,
};

export const reducer = createReducer<State>(
Copy link
Member

@timdeschryver timdeschryver Apr 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ons don't need to be wrapped in an array:

export const reducer = createReducer<State>(
  initialState,
  on(AuthActions.login, (): State => ({ loggedIn: true })),
  on(AuthActions.logout, (): State => ({ loggedIn: false }))
);

This should also resolve the stackblitz error that you're encountering

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you're in that file we should also add tslib:

image

(this change is needed for all the examples - this dep was needed for store migrations)

[
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[
initialState,
[

on(AuthActions.login, (): State => ({ loggedIn: true })),
on(AuthActions.logout, (): State => ({ loggedIn: false }))
],
initialState
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove initialState as the last argument

);

export const getLoggedIn = (state: State) => state.loggedIn;
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
createSelector,
createFeatureSelector,
Action,
combineReducers,
} from '@ngrx/store';
import * as fromAuth from './auth.reducer';

export interface AuthState {
status: fromAuth.State;
}

export interface State {
auth: AuthState;
}

export const selectAuthState = createFeatureSelector<AuthState>('auth');

export const selectAuthStatusState = createSelector(
selectAuthState,
(state: AuthState) => state.status
);

export const getLoggedIn = createSelector(
selectAuthStatusState,
fromAuth.getLoggedIn
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<title>Angular App</title>
</head>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.99.0/jasmine.css"> to style the test page

<body>
<!-- Intentionally empty -->
</body>
</html>
45 changes: 45 additions & 0 deletions projects/ngrx.io/content/examples/testing-store/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import './testing/global-jasmine';
import 'jasmine-core/lib/jasmine-core/jasmine-html.js';
import 'jasmine-core/lib/jasmine-core/boot.js';

declare var jasmine;

import './polyfills';

import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';

import { getTestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

// Spec files to include in the Stackblitz tests
import './test-files.ts';

//

bootstrap();

//

function bootstrap () {
if (window['jasmineRef']) {
location.reload();
return;
} else {
window.onload(undefined);
window['jasmineRef'] = jasmine.getEnv();
}

// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Import spec files individually for Stackblitz
import './app/auth-guard.service.spec';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import jasmineRequire from 'jasmine-core/lib/jasmine-core/jasmine.js';

window['jasmineRequire'] = jasmineRequire;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"description": "Store Testing Tutorial",
"files": ["!**/*.d.ts", "!**/*.js", "**/*.spec.ts"]
}
18 changes: 18 additions & 0 deletions projects/ngrx.io/content/guide/store/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ describe('Auth Guard', () => {
});
</code-example>

#### Using Mock Selectors
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#### Using Mock Selectors
### Using Mock Selectors


`MockStore` also provides the ability to mock individual selectors to return a passed value using the `overrideSelector()` method. When the selector is invoked by the `select` method, the returned value is overridden by the passed value, regardless of any state snapshot provided in `provideMockStore()`.
jtcrowson marked this conversation as resolved.
Show resolved Hide resolved

`overrideSelector()` returns a `MemoizedSelector`. To update the mock selector to return a different value, use the `MemoizedSelector`'s `setResult()` method.

`overrideSelector()` supports mocking the `select` method (used in RxJS pipe) and the `Store` `select` instance method using a string or selector.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you wanted to say setResult here?
Could we also simplify the wording as the following?

Suggested change
`overrideSelector()` supports mocking the `select` method (used in RxJS pipe) and the `Store` `select` instance method using a string or selector.
`setResult()` supports mocking the `select` method and returns the given input provided in `setResult()`.

Copy link
Contributor Author

@jtcrowson jtcrowson Apr 25, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intended to use overrideSelector and summarize the note from #1688:

Covers mocking different types of selectors:

store.select('slice');
const selector = createSelector(state, state => state.slice);
store.select(selector);
const selector = createSelector(state, state => state.slice);
store.pipe(select(selector));


Usage:

<code-example header="auth-guard.service.ts" path="testing-store/src/app/auth-guard.service.ts"></code-example>

<code-example header="auth-guard.service.spec.ts" path="testing-store/src/app/auth-guard.service.spec.ts"></code-example>

In this example, we mock the `getLoggedIn` selector by using `overrideSelector`, passing in the `getLoggedIn` selector with a default mocked return value of `false`. In the second test, we use `setResult()` to update the mock selector to return `true`.

Try the <live-example name="testing-store"></live-example>.

### Using Store for Integration Testing

Use the `StoreModule.forRoot` in your `TestBed` configuration when testing components or services that inject `Store`.
Expand Down