Merge pull request #39 from artlowel/reducer-tests

Reducer tests
This commit is contained in:
William Welling
2017-01-25 07:40:23 -06:00
committed by GitHub
16 changed files with 414 additions and 73 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
npm-debug.log npm-debug.log
/dist/ /dist/
/coverage/
.idea .idea
*.ngfactory.ts *.ngfactory.ts

View File

@@ -106,6 +106,7 @@
"@types/body-parser": "0.0.33", "@types/body-parser": "0.0.33",
"@types/compression": "0.0.33", "@types/compression": "0.0.33",
"@types/cookie-parser": "1.3.30", "@types/cookie-parser": "1.3.30",
"@types/deep-freeze": "0.0.29",
"@types/express": "4.0.34", "@types/express": "4.0.34",
"@types/express-serve-static-core": "4.0.39", "@types/express-serve-static-core": "4.0.39",
"@types/hammerjs": "2.0.33", "@types/hammerjs": "2.0.33",
@@ -126,6 +127,7 @@
"cookie-parser": "1.4.3", "cookie-parser": "1.4.3",
"copy-webpack-plugin": "4.0.1", "copy-webpack-plugin": "4.0.1",
"css-loader": "^0.26.0", "css-loader": "^0.26.0",
"deep-freeze": "0.0.1",
"html-webpack-plugin": "^2.21.0", "html-webpack-plugin": "^2.21.0",
"imports-loader": "0.7.0", "imports-loader": "0.7.0",
"istanbul-instrumenter-loader": "^0.2.0", "istanbul-instrumenter-loader": "^0.2.0",

View File

@@ -10,36 +10,36 @@ import {
DebugElement DebugElement
} from "@angular/core"; } from "@angular/core";
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TranslateModule } from "ng2-translate"; import { TranslateModule, TranslateLoader } from "ng2-translate";
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; import { Store, StoreModule } from "@ngrx/store";
import { Store } from "@ngrx/store";
// Load the implementations that should be tested // Load the implementations that should be tested
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HostWindowState } from "./shared/host-window.reducer";
import { HostWindowResizeAction } from "./shared/host-window.actions";
import { MockTranslateLoader } from "./shared/testing/mock-translate-loader";
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
let de: DebugElement; let de: DebugElement;
let el: HTMLElement; let el: HTMLElement;
describe('App component', () => { describe('App component', () => {
// async beforeEach // async beforeEach
beforeEach(async(() => { beforeEach(async(() => {
return TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [ CommonModule, TranslateModule.forRoot(), NgbCollapseModule.forRoot()], imports: [CommonModule, StoreModule.provideStore({}), TranslateModule.forRoot({
declarations: [ AppComponent, HeaderComponent ], // declare the test component provide: TranslateLoader,
useClass: MockTranslateLoader
})],
declarations: [AppComponent], // declare the test component
providers: [ providers: [
AppComponent, AppComponent
{
provide: Store,
useClass: class { dispatch = jasmine.createSpy('dispatch') }
}
], ],
schemas: [ CUSTOM_ELEMENTS_SCHEMA ] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
})); }));
@@ -47,7 +47,7 @@ describe('App component', () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(AppComponent); fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance; // BannerComponent test instance comp = fixture.componentInstance; // component test instance
// query for the title <p> by CSS element selector // query for the title <p> by CSS element selector
de = fixture.debugElement.query(By.css('p')); de = fixture.debugElement.query(By.css('p'));
@@ -58,4 +58,24 @@ describe('App component', () => {
// Perform test using fixture and service // Perform test using fixture and service
expect(app).toBeTruthy(); expect(app).toBeTruthy();
})); }));
describe("when the window is resized", () => {
let width: number;
let height: number;
let store: Store<HostWindowState>;
beforeEach(() => {
store = fixture.debugElement.injector.get(Store);
spyOn(store, 'dispatch');
window.dispatchEvent(new Event('resize'));
width = window.innerWidth;
height = window.innerHeight;
});
it("should dispatch a HostWindowResizeAction with the width and height of the window as its payload", () => {
expect(store.dispatch).toHaveBeenCalledWith(new HostWindowResizeAction(width, height));
});
});
}); });

View File

@@ -8,7 +8,7 @@ import {
import { TranslateService } from "ng2-translate"; import { TranslateService } from "ng2-translate";
import { HostWindowState } from "./shared/host-window.reducer"; import { HostWindowState } from "./shared/host-window.reducer";
import { Store } from "@ngrx/store"; import { Store } from "@ngrx/store";
import { HostWindowActions } from "./shared/host-window.actions"; import { HostWindowResizeAction } from "./shared/host-window.actions";
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.Default, changeDetection: ChangeDetectionStrategy.Default,
@@ -52,7 +52,7 @@ export class AppComponent implements OnDestroy, OnInit {
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
private onResize(event): void { private onResize(event): void {
this.store.dispatch( this.store.dispatch(
HostWindowActions.resize(event.target.innerWidth, event.target.innerHeight) new HostWindowResizeAction(event.target.innerWidth, event.target.innerHeight)
); );
} }

View File

@@ -1,24 +1,43 @@
import { Action } from "@ngrx/store"; import { Action } from "@ngrx/store";
import { type } from "../shared/ngrx/type";
export class HeaderActions { /**
static COLLAPSE = 'dspace/header/COLLAPSE'; * For each action type in an action group, make a simple
static collapse(): Action { * enum object for all of this group's action types.
return { *
type: HeaderActions.COLLAPSE * The 'type' utility function coerces strings into string
} * literal types and runs a simple check to guarantee all
} * action types in the application are unique.
*/
export const HeaderActionTypes = {
COLLAPSE: type('dspace/header/COLLAPSE'),
EXPAND: type('dspace/header/EXPAND'),
TOGGLE: type('dspace/header/TOGGLE')
};
static EXPAND = 'dspace/header/EXPAND'; export class HeaderCollapseAction implements Action {
static expand(): Action { type = HeaderActionTypes.COLLAPSE;
return {
type: HeaderActions.EXPAND
}
}
static TOGGLE = 'dspace/header/TOGGLE'; constructor() {}
static toggle(): Action {
return {
type: HeaderActions.TOGGLE
}
}
} }
export class HeaderExpandAction implements Action {
type = HeaderActionTypes.EXPAND;
constructor() {}
}
export class HeaderToggleAction implements Action {
type = HeaderActionTypes.TOGGLE;
constructor() {}
}
/**
* Export a type alias of all actions in this action group
* so that reducers can easily compose action types
*/
export type HeaderAction
= HeaderCollapseAction
| HeaderExpandAction
| HeaderToggleAction

View File

@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HeaderComponent } from "./header.component";
import { Store, StoreModule } from "@ngrx/store";
import { HeaderState } from "./header.reducer";
import Spy = jasmine.Spy;
import { HeaderToggleAction } from "./header.actions";
import { TranslateModule } from "ng2-translate";
import { NgbCollapseModule } from "@ng-bootstrap/ng-bootstrap";
import { Observable } from "rxjs";
let comp: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>;
let store: Store<HeaderState>;
describe('HeaderComponent', () => {
// async beforeEach
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ StoreModule.provideStore({}), TranslateModule.forRoot(), NgbCollapseModule.forRoot() ],
declarations: [ HeaderComponent ]
})
.compileComponents(); // compile template and css
}));
// synchronous beforeEach
beforeEach(() => {
fixture = TestBed.createComponent(HeaderComponent);
comp = fixture.componentInstance;
store = fixture.debugElement.injector.get(Store);
spyOn(store, 'dispatch');
});
describe('when the toggle button is clicked', () => {
beforeEach(() => {
let navbarToggler = fixture.debugElement.query(By.css('.navbar-toggler'));
navbarToggler.triggerEventHandler('click', null);
});
it("should dispatch a HeaderToggleAction", () => {
expect(store.dispatch).toHaveBeenCalledWith(new HeaderToggleAction());
});
});
describe("when navCollapsed in the store is true", () => {
let menu: HTMLElement;
beforeEach(() => {
menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement;
spyOn(store, 'select').and.returnValue(Observable.of({ navCollapsed: true }));
fixture.detectChanges();
});
it("should close the menu", () => {
expect(menu.classList).not.toContain('in');
});
});
describe("when navCollapsed in the store is false", () => {
let menu: HTMLElement;
beforeEach(() => {
menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement;
spyOn(store, 'select').and.returnValue(Observable.of({ navCollapsed: false }));
fixture.detectChanges();
});
it("should open the menu", () => {
expect(menu.classList).toContain('in');
});
});
});

View File

@@ -1,9 +1,8 @@
import { Component, OnInit } from "@angular/core"; import { Component, OnInit } from "@angular/core";
import { Store } from "@ngrx/store"; import { Store } from "@ngrx/store";
import { HeaderState } from "./header.reducer"; import { HeaderState } from "./header.reducer";
import { HeaderActions } from "./header.actions";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import 'rxjs/add/operator/filter'; import { HeaderToggleAction } from "./header.actions";
@Component({ @Component({
selector: 'ds-header', selector: 'ds-header',
@@ -24,16 +23,8 @@ export class HeaderComponent implements OnInit {
.map(({ navCollapsed }: HeaderState) => navCollapsed); .map(({ navCollapsed }: HeaderState) => navCollapsed);
} }
private collapse(): void {
this.store.dispatch(HeaderActions.collapse());
}
private expand(): void {
this.store.dispatch(HeaderActions.expand());
}
public toggle(): void { public toggle(): void {
this.store.dispatch(HeaderActions.toggle()); this.store.dispatch(new HeaderToggleAction());
} }
} }

View File

@@ -0,0 +1,53 @@
import { TestBed, inject } from "@angular/core/testing";
import { EffectsTestingModule, EffectsRunner } from '@ngrx/effects/testing';
import { HeaderEffects } from "./header.effects";
import { HeaderCollapseAction } from "./header.actions";
import { HostWindowResizeAction } from "../shared/host-window.actions";
import { routerActions } from "@ngrx/router-store";
describe('HeaderEffects', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [
EffectsTestingModule
],
providers: [
HeaderEffects
]
}));
let runner: EffectsRunner;
let headerEffects: HeaderEffects;
beforeEach(inject([
EffectsRunner, HeaderEffects
],
(_runner, _headerEffects) => {
runner = _runner;
headerEffects = _headerEffects;
}
));
describe('resize$', () => {
it('should return a COLLAPSE action in response to a RESIZE action', () => {
runner.queue(new HostWindowResizeAction(800,600));
headerEffects.resize$.subscribe(result => {
expect(result).toEqual(new HeaderCollapseAction());
});
});
});
describe('routeChange$', () => {
it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => {
runner.queue({ type: routerActions.UPDATE_LOCATION });
headerEffects.resize$.subscribe(result => {
expect(result).toEqual(new HeaderCollapseAction());
});
});
});
});

View File

@@ -1,8 +1,8 @@
import { Injectable } from "@angular/core"; import { Injectable } from "@angular/core";
import { Effect, Actions } from '@ngrx/effects' import { Effect, Actions } from '@ngrx/effects'
import { HeaderActions } from "./header.actions"; import { HostWindowActionTypes } from "../shared/host-window.actions";
import { HostWindowActions } from "../shared/host-window.actions";
import { routerActions } from "@ngrx/router-store"; import { routerActions } from "@ngrx/router-store";
import { HeaderCollapseAction } from "./header.actions";
@Injectable() @Injectable()
export class HeaderEffects { export class HeaderEffects {
@@ -12,10 +12,10 @@ export class HeaderEffects {
) { } ) { }
@Effect() resize$ = this.actions$ @Effect() resize$ = this.actions$
.ofType(HostWindowActions.RESIZE) .ofType(HostWindowActionTypes.RESIZE)
.map(() => HeaderActions.collapse()); .map(() => new HeaderCollapseAction());
@Effect() routeChange$ = this.actions$ @Effect() routeChange$ = this.actions$
.ofType(routerActions.UPDATE_LOCATION) .ofType(routerActions.UPDATE_LOCATION)
.map(() => HeaderActions.collapse()); .map(() => new HeaderCollapseAction());
} }

View File

@@ -0,0 +1,89 @@
import * as deepFreeze from "deep-freeze";
import { headerReducer } from "./header.reducer";
import {
HeaderCollapseAction,
HeaderExpandAction,
HeaderToggleAction
} from "./header.actions";
class NullAction extends HeaderCollapseAction {
type = null;
constructor() {
super();
}
}
describe("headerReducer", () => {
it("should return the current state when no valid actions have been made", () => {
const state = { navCollapsed: false };
const action = new NullAction();
const newState = headerReducer(state, action);
expect(newState).toEqual(state);
});
it("should start with navCollapsed = true", () => {
const action = new NullAction();
const initialState = headerReducer(undefined, action);
// The navigation starts collapsed
expect(initialState.navCollapsed).toEqual(true);
});
it("should set navCollapsed to true in response to the COLLAPSE action", () => {
const state = { navCollapsed: false };
const action = new HeaderCollapseAction();
const newState = headerReducer(state, action);
expect(newState.navCollapsed).toEqual(true);
});
it("should perform the COLLAPSE action without affecting the previous state", () => {
const state = { navCollapsed: false };
deepFreeze(state);
const action = new HeaderCollapseAction();
headerReducer(state, action);
//no expect required, deepFreeze will ensure an exception is thrown if the state
//is mutated, and any uncaught exception will cause the test to fail
});
it("should set navCollapsed to false in response to the EXPAND action", () => {
const state = { navCollapsed: true };
const action = new HeaderExpandAction();
const newState = headerReducer(state, action);
expect(newState.navCollapsed).toEqual(false);
});
it("should perform the EXPAND action without affecting the previous state", () => {
const state = { navCollapsed: true };
deepFreeze(state);
const action = new HeaderExpandAction();
headerReducer(state, action);
});
it("should flip the value of navCollapsed in response to the TOGGLE action", () => {
const state1 = { navCollapsed: true };
const action = new HeaderToggleAction();
const state2 = headerReducer(state1, action);
const state3 = headerReducer(state2, action);
expect(state2.navCollapsed).toEqual(false);
expect(state3.navCollapsed).toEqual(true);
});
it("should perform the TOGGLE action without affecting the previous state", () => {
const state = { navCollapsed: true };
deepFreeze(state);
const action = new HeaderToggleAction();
headerReducer(state, action);
});
});

View File

@@ -1,5 +1,4 @@
import { Action } from "@ngrx/store"; import { HeaderAction, HeaderActionTypes } from "./header.actions";
import { HeaderActions } from "./header.actions";
export interface HeaderState { export interface HeaderState {
navCollapsed: boolean; navCollapsed: boolean;
@@ -9,23 +8,23 @@ const initialState: HeaderState = {
navCollapsed: true navCollapsed: true
}; };
export const headerReducer = (state = initialState, action: Action): HeaderState => { export const headerReducer = (state = initialState, action: HeaderAction): HeaderState => {
switch (action.type) { switch (action.type) {
case HeaderActions.COLLAPSE: { case HeaderActionTypes.COLLAPSE: {
return Object.assign({}, state, { return Object.assign({}, state, {
navCollapsed: true navCollapsed: true
}); });
} }
case HeaderActions.EXPAND: { case HeaderActionTypes.EXPAND: {
return Object.assign({}, state, { return Object.assign({}, state, {
navCollapsed: false navCollapsed: false
}); });
} }
case HeaderActions.TOGGLE: { case HeaderActionTypes.TOGGLE: {
return Object.assign({}, state, { return Object.assign({}, state, {
navCollapsed: !state.navCollapsed navCollapsed: !state.navCollapsed
}); });

View File

@@ -1,14 +1,21 @@
import { Action } from "@ngrx/store"; import { Action } from "@ngrx/store";
import { type } from "./ngrx/type";
export class HostWindowActions { export const HostWindowActionTypes = {
static RESIZE = 'dspace/host-window/RESIZE'; RESIZE: type('dspace/host-window/RESIZE')
static resize(newWidth: number, newHeight: number): Action { };
return {
type: HostWindowActions.RESIZE, export class HostWindowResizeAction implements Action {
payload: { type = HostWindowActionTypes.RESIZE;
width: newWidth, payload: {
height: newHeight width: number;
} height: number;
} };
constructor(width: number, height: number) {
this.payload = { width, height }
} }
} }
export type HostWindowAction
= HostWindowResizeAction;

View File

@@ -0,0 +1,48 @@
import * as deepFreeze from "deep-freeze";
import { hostWindowReducer } from "./host-window.reducer";
import { HostWindowResizeAction } from "./host-window.actions";
class NullAction extends HostWindowResizeAction {
type = null;
constructor() {
super(0,0);
}
}
describe('hostWindowReducer', () => {
it("should return the current state when no valid actions have been made", () => {
const state = { width: 800, height: 600 };
const action = new NullAction();
const newState = hostWindowReducer(state, action);
expect(newState).toEqual(state);
});
it("should start with width = null and height = null", () => {
const action = new NullAction();
const initialState = hostWindowReducer(undefined, action);
expect(initialState.width).toEqual(null);
expect(initialState.height).toEqual(null);
});
it("should update the width and height in the state in response to a RESIZE action", () => {
const state = { width: 800, height: 600 };
const action = new HostWindowResizeAction(1024, 768);
const newState = hostWindowReducer(state, action);
expect(newState.width).toEqual(1024);
expect(newState.height).toEqual(768);
});
it("should perform the RESIZE action without affecting the previous state", () => {
const state = { width: 800, height: 600 };
deepFreeze(state);
const action = new HostWindowResizeAction(1024, 768);
hostWindowReducer(state, action);
});
});

View File

@@ -1,5 +1,4 @@
import { Action } from "@ngrx/store"; import { HostWindowAction, HostWindowActionTypes } from "./host-window.actions";
import { HostWindowActions } from "./host-window.actions";
export interface HostWindowState { export interface HostWindowState {
width: number; width: number;
@@ -11,10 +10,10 @@ const initialState: HostWindowState = {
height: null height: null
}; };
export const hostWindowReducer = (state = initialState, action: Action): HostWindowState => { export const hostWindowReducer = (state = initialState, action: HostWindowAction): HostWindowState => {
switch (action.type) { switch (action.type) {
case HostWindowActions.RESIZE: { case HostWindowActionTypes.RESIZE: {
return Object.assign({}, state, action.payload); return Object.assign({}, state, action.payload);
} }

View File

@@ -0,0 +1,24 @@
/**
* Based on
* https://github.com/ngrx/example-app/blob/master/src/app/util.ts
*
* This function coerces a string into a string literal type.
* Using tagged union types in TypeScript 2.0, this enables
* powerful typechecking of our reducers.
*
* Since every action label passes through this function it
* is a good place to ensure all of our action labels
* are unique.
*/
let typeCache: { [label: string]: boolean } = {};
export function type<T>(label: T | ''): T {
if (typeCache[<string>label]) {
throw new Error(`Action type "${label}" is not unique"`);
}
typeCache[<string>label] = true;
return <T>label;
}

View File

@@ -0,0 +1,8 @@
import { TranslateLoader } from "ng2-translate";
import { Observable } from "rxjs";
export class MockTranslateLoader implements TranslateLoader {
getTranslation(lang: string): Observable<any> {
return Observable.of({});
}
}