Skip to content

Commit

Permalink
Merge pull request #50 from nbeach/better-errors
Browse files Browse the repository at this point in the history
Better errors
  • Loading branch information
nbeach committed Aug 9, 2018
2 parents c561fcf + 69b5061 commit 0806c74
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 31 deletions.
15 changes: 15 additions & 0 deletions src/exception.ts
@@ -0,0 +1,15 @@
import isNil = require("lodash/fp/isNil")
import includes = require("lodash/fp/includes")

export function throwIfNil(value: any, error: string): void {
if (isNil(value)) {
throw new Error(error)
}
}

export function throwIfNotIn(value: any, list: any[], message: string) {
if (!includes(value, list)) {
throw new Error(message)
}
}

5 changes: 2 additions & 3 deletions src/mock-component.ts
@@ -1,14 +1,13 @@
import {Component, EventEmitter, Input, Output, Type} from "@angular/core"
import {selectorOf} from "./selector-of"
import {extend, keys, keysIn, reduce, set, some, defaultTo} from "lodash"
import {extend, keys, keysIn, reduce, set, some} from "lodash"
import {stub} from "sinon"
import {propertyMetadata} from "./reflection"

let _mockProvider = stub

function propertiesDecoratedWith(decorator: any, propertyMetadata: any): string[] {
const metadata = defaultTo(propertyMetadata, {})
return keys(metadata).filter((key: any) => instanceExistsIn(decorator, propertyMetadata[key]))
return keys(propertyMetadata).filter((key: any) => instanceExistsIn(decorator, propertyMetadata[key]))
}

function instanceExistsIn<T>(object: Type<T>, list: any[]): boolean {
Expand Down
56 changes: 56 additions & 0 deletions src/reflection.spec.ts
@@ -0,0 +1,56 @@
import {Component, Injectable, Input, Output} from "@angular/core"
import {getAnnotation, propertyMetadata} from "./reflection"
import {expect} from "chai"

describe("getAnnotation()", () => {

it("returns the annotation object for the class", () => {
@Component({ selector: "foo" })
class SomeComponent {}

expect(getAnnotation(SomeComponent, Component).selector).to.equal("foo")
})


it("returns undefined if the class is annotated but not with the provided decorator", () => {
@Injectable()
class SomeComponent {}

expect(getAnnotation(SomeComponent, Component)).to.to.be.undefined
})

it("returns undefined if the class has no decorators", () => {
class SomeComponent {}

expect(getAnnotation(SomeComponent, Component)).to.to.be.undefined
})
})


describe("propertyMetadata()", () => {

it("returns metadata for decorated properties", () => {

class SomeComponent {
@Input("foo") public one: string = ""
@Output("bar") public two: string = ""
}

const actual = propertyMetadata(SomeComponent)

expect(actual.one[0].bindingPropertyName).to.equal("foo")
expect(actual.two[0].bindingPropertyName).to.equal("bar")
})

it("returns an empty object when no properties are decorated", () => {
class SomeComponent {
public one: string = ""
public two: string = ""
}

const actual = propertyMetadata(SomeComponent)

expect(actual).to.deep.equal({})
})

})
11 changes: 7 additions & 4 deletions src/reflection.ts
@@ -1,11 +1,14 @@
import {Type} from "@angular/core"
import "reflect-metadata"

export function getAnnotation(object: Type<any>, annotation: any): any {
const annotations = (object as any).__annotations__ || Reflect.getMetadata("annotations", object)
return annotations.filter((attachedAnnotation: any) => attachedAnnotation instanceof annotation)[0]
export function getAnnotation(object: Type<any>, annotationType: any): any | undefined {
const annotations = (object as any).__annotations__ || Reflect.getMetadata("annotations", object) || []
const [annotation] = annotations
.filter((attachedAnnotation: any) => attachedAnnotation instanceof annotationType)

return annotation
}

export function propertyMetadata(object: Type<any>): any {
return (object as any).__prop__metadata__ || Reflect.getMetadata("propMetadata", object)
return (object as any).__prop__metadata__ || Reflect.getMetadata("propMetadata", object) || {}
}
14 changes: 13 additions & 1 deletion src/selector-of.spec.ts
Expand Up @@ -5,12 +5,24 @@ import {expect} from "chai"
describe("selectorOf", () => {

it("returns the selector for the component", () => {

@Component({
selector: ".abc123",
})
class SomeComponent {}

expect(selectorOf(SomeComponent)).to.equal(".abc123")
})

it("throws an error if no selector is set", () => {
@Component({})
class SomeComponent {}

expect(() => selectorOf(SomeComponent)).to.throw("Component does not have a selector set")
})

it("throws an error if object is not a component", () => {
class SomeClass {}

expect(() => selectorOf(SomeClass)).to.throw("Provided value is not a Component")
})
})
9 changes: 8 additions & 1 deletion src/selector-of.ts
@@ -1,8 +1,15 @@
import {getAnnotation} from "./reflection"
import {Component, Type} from "@angular/core"
import {throwIfNil} from "./exception"

export function selectorOf(component: Type<any>) {
return getAnnotation(component, Component).selector
const annotation = getAnnotation(component, Component)
throwIfNil(annotation, "Provided value is not a Component")

const selector = annotation.selector
throwIfNil(selector, "Component does not have a selector set")

return selector
}


41 changes: 40 additions & 1 deletion src/test-component.spec.ts
Expand Up @@ -12,7 +12,7 @@ import {
teardown,
testComponent,
} from "./index"
import {Component, EventEmitter, Input, OnInit, Output} from "@angular/core"
import {Component, Directive, EventEmitter, Input, OnInit, Output} from "@angular/core"
import {expect} from "chai"
import {By} from "@angular/platform-browser"
import {FormsModule} from "@angular/forms"
Expand Down Expand Up @@ -425,4 +425,43 @@ describe("TestSetup", () => {
expect(scenario.method).to.throw("You must first start a test using .begin() before using this method")
})

it("testComponent() throws an exception when a test is in progress", () => {
@Component({selector: "test-component", template: ""})
class SubjectComponent {}

testComponent(SubjectComponent).begin()

expect(() => testComponent(SubjectComponent)).to.throw("You cannot configure a test while a test already is in progress")
})

where([
["method" ],
["setInput" ],
["onOutput" ],
["import" ],
["providers"],
["use" ],
["mock" ],
["setupMock"],
]).it("TestBuilder.#method() throws an exception when a test is in progress", (scenario: any) => {
@Component({selector: "test-component", template: ""})
class SubjectComponent {}

const builder = testComponent(SubjectComponent)
builder.begin()

expect(builder[scenario.method].bind(builder)).to.throw("You cannot configure a test while a test already is in progress")
})

it("throw an exception when the user attempts to mock something that isn't a Component", () => {
@Component({selector: "test-component", template: ""})
class SubjectComponent {}

@Directive({selector: "some-directive"})
class SomeDirective {}

expect(() => testComponent(SubjectComponent).mock([SomeDirective])).to.throw("Cannot mock SomeDirective. Only mocking of Components is supported.")

})

})
65 changes: 44 additions & 21 deletions src/test-component.ts
@@ -1,60 +1,54 @@
import {ComponentFixture, TestBed} from "@angular/core/testing"
import {Type} from "@angular/core"
import {concat, includes} from "lodash"
import {Component, Type} from "@angular/core"
import {concat} from "lodash"
import {selectComponent, selectComponents} from "./dom"
import {mockComponent, MockSetup} from "./mock-component"
import {selectorOf} from "./selector-of"
import {default as createTestHostComponent, MockTypeAndSetup, OutputWatch} from "./test-host"
import {throwIfNil, throwIfNotIn} from "./exception"
import {getAnnotation} from "./reflection"

const testNotStartedError = new Error("You must first start a test using .begin() before using this method")

function throwIfNull(value: any, error: Error) {
if (value === null) {
throw error
}
}
const testNotStartedMessage = "You must first start a test using .begin() before using this method"

let _subject: any = null
let _subjectElement: Element | null = null
let _fixture: ComponentFixture<any> | null = null
let _initializedInputs: string[] = []
let _testInProgress: boolean = false

export const element = (selector: string): Element | null => subjectElement().querySelector(selector)
export const elements = (selector: string): Element[] => Array.from(subjectElement().querySelectorAll(selector))
export const component = <T>(selectorOrType: string | Type<T>): T => selectComponent(selectorOrType, fixture())
export const components = <T>(selectorOrType: string | Type<T>): T[] => selectComponents(selectorOrType, fixture())
export const setInput = (inputName: string, value: any) => {
export const setInput = (inputName: string, value: any): void => {
const testFixture = fixture()
if (!includes(_initializedInputs, inputName)) {
throw new Error("In order to set an input after begin() is called you must provide an initial value with .setInput() at test setup time.")
}

return testFixture.componentInstance[inputName] = value
throwIfNotIn(inputName, _initializedInputs, "In order to set an input after begin() is called you must provide an initial value with .setInput() at test setup time.")
testFixture.componentInstance[inputName] = value
}
export const onOutput = (outputName: string, action: (event: any) => void) => subject()[outputName].subscribe(action)
export const onOutput = (outputName: string, action: (event: any) => void): void => subject()[outputName].subscribe(action)
export const detectChanges = (): void => fixture().detectChanges()
export const teardown = (): void => {
TestBed.resetTestingModule()
_subject = null
_subjectElement = null
_fixture = null
_initializedInputs = []
_testInProgress = false
}

export function subject<T>(): T {
throwIfNull(_subject, testNotStartedError)
throwIfNil(_subject, testNotStartedMessage)
return _subject
}
export function subjectElement(): Element {
throwIfNull(_subjectElement, testNotStartedError)
throwIfNil(_subjectElement, testNotStartedMessage)
return _subjectElement!
}
export function fixture(): ComponentFixture<any> {
throwIfNull(_fixture, testNotStartedError)
throwIfNil(_fixture, testNotStartedMessage)
return _fixture!
}


export const testComponent = <T>(subject: Type<T>) => new TestBuilder(subject)

export class TestBuilder<T> {
Expand All @@ -67,39 +61,56 @@ export class TestBuilder<T> {
private inputInitializations = new Map<string, any>()
private outputWatches: OutputWatch[] = []

constructor(private subject: Type<T>) {}
constructor(private subject: Type<T>) {
throwIfTestAlreadyInProgress()
}

public setupMock(type: Type<any>, setup: (mock: any) => void): TestBuilder<T> {
throwIfTestAlreadyInProgress()

this.mockSetups.push({type, setup})
return this
}

public setInput(inputName: string, value: any): TestBuilder<T> {
throwIfTestAlreadyInProgress()

this.inputInitializations.set(inputName, value)
return this
}

public onOutput(outputName: string, action: (event: any) => void): TestBuilder<T> {
throwIfTestAlreadyInProgress()

this.outputWatches.push({ name: outputName, action })
return this
}

public mock(components: Type<any>[]) {
throwIfTestAlreadyInProgress()
components.forEach(throwIfNotComponent)

this._mock = components
return this
}

public use(components: Type<any>[]) {
throwIfTestAlreadyInProgress()

this._use = components
return this
}

public providers(providers: any[]) {
throwIfTestAlreadyInProgress()

this._providers = providers
return this
}

public import(imports: any[]) {
throwIfTestAlreadyInProgress()

this._imports = imports
return this
}
Expand All @@ -120,6 +131,8 @@ export class TestBuilder<T> {
_initializedInputs = Array.from(this.inputInitializations.keys())

_subjectElement = _fixture.nativeElement.querySelector(selectorOf(this.subject))

_testInProgress = true
return _subject as T
}

Expand All @@ -137,3 +150,13 @@ function applyMockSetups(mock: any, mockSetups: MockSetup[]): void {
mockSetups.forEach(setup => setup(mock))
}

function throwIfNotComponent(constructor: Type<any>): void {
const componentAnnotation = getAnnotation(constructor, Component)
throwIfNil(componentAnnotation, `Cannot mock ${constructor.name}. Only mocking of Components is supported.`)
}

function throwIfTestAlreadyInProgress() {
if (_testInProgress) {
throw new Error("You cannot configure a test while a test already is in progress")
}
}

0 comments on commit 0806c74

Please sign in to comment.