Skip to content

Commit

Permalink
feat(ng-dev): add MockErrorHandler
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed Nov 10, 2021
1 parent 63bf138 commit d83353e
Show file tree
Hide file tree
Showing 7 changed files with 477 additions and 136 deletions.
2 changes: 2 additions & 0 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

212 changes: 212 additions & 0 deletions projects/ng-dev/src/lib/mock-error-handler.spec.ts
@@ -0,0 +1,212 @@
import { ErrorHandler } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { AngularContext } from './angular-context';
import { MockErrorHandler } from './mock-error-handler';
import { expectSingleCallAndReset } from './spies';

describe('MockErrorHandler', () => {
let consoleSpy: jasmine.Spy;
beforeEach(() => {
consoleSpy = spyOn(console, 'error');
});

describe('createProvider()', () => {
it('makes MockErrorHandler the ErrorHandler', () => {
TestBed.configureTestingModule({
providers: [MockErrorHandler.createProvider()],
});
expect(TestBed.inject(ErrorHandler)).toBe(
TestBed.inject(MockErrorHandler),
);
});
});

describe('handleError()', () => {
it("calls through to Angular's handleError()", () => {
TestBed.inject(MockErrorHandler).handleError('blah');
expectSingleCallAndReset(consoleSpy, 'ERROR', 'blah');
});
});

describe('.expectOne()', () => {
it('finds a matching error', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(new Error('error 1'));
handler.handleError(new Error('error 2'));

const match = handler.expectOne('error 2');

expect(match.message).toEqual('error 2');
});

it('throws when there is no match', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError('blah');

expect(() => {
handler.expectOne(() => false);
}).toThrowError(
'Expected one matching error(s) for criterion "Match by function: ", found 0',
);
});

it('throws when there have been no errors', () => {
const handler = TestBed.inject(MockErrorHandler);

expect(() => {
handler.expectOne(() => true);
}).toThrowError(
'Expected one matching error(s) for criterion "Match by function: ", found 0',
);
});

it('throws when there is more than one match', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError('blah');
handler.handleError('blah');

expect(() => {
handler.expectOne(() => true);
}).toThrowError(
'Expected one matching error(s) for criterion "Match by function: ", found 2',
);
});
});

describe('expectNone()', () => {
it('throws if any error matches', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(new Error('error 1'));
handler.handleError(new Error('error 2'));

expect(() => {
handler.expectNone('error 2');
}).toThrowError(
'Expected zero matching error(s) for criterion "Match by string: error 2", found 1',
);
});

it('does not throw when no error matches', () => {
const handler = TestBed.inject(MockErrorHandler);

expect(() => {
handler.expectNone(() => false);
}).not.toThrowError();
});
});

describe('match()', () => {
it('finds matching errors', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError('error 1');
handler.handleError('error 2');
handler.handleError('error 3');

const matches = handler.match((error) => error !== 'error 2');

expect(matches).toEqual(['error 1', 'error 3']);
});

it('accepts string shorthand', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(new Error('error 1'));
handler.handleError(new Error('error 2'));
handler.handleError(new Error('error 3'));

const matches = handler.match('error 2');

expect(matches).toEqual([new Error('error 2')]);
});

it('accepts RegExp shorthand', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(new Error('error 1'));
handler.handleError(new Error('error 2'));
handler.handleError(new Error('error 3'));

const matches = handler.match(/2/);

expect(matches).toEqual([new Error('error 2')]);
});

it('removes the matching calls from future matching', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(new Error('error 1'));
handler.handleError(new Error('error 2'));

handler.match('error 2');

expect(handler.match(() => true)).toEqual([new Error('error 1')]);
});

it('returns an empty array when there have been no errors', () => {
expect(TestBed.inject(MockErrorHandler).match(() => false)).toEqual([]);
});

it('gracefully handles when no errors match', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(new Error('error 1'));
expect(handler.match(() => false)).toEqual([]);
});

it('gracefully handles cleverly constructed errors that try to cause errors', () => {
const handler = TestBed.inject(MockErrorHandler);
handler.handleError(undefined);
handler.handleError({ messages: new Date() });
handler.handleError({ messages: { matches: new Date() } });
expect(() => {
handler.match('a string');
handler.match(/a regexp/);
handler.match(() => true);
}).not.toThrowError();
});
});

describe('verify()', () => {
it('does not throw when all errors have been expected', () => {
const handler = TestBed.inject(MockErrorHandler);

// no error when no calls were made at all
expect(() => {
handler.verify();
}).not.toThrowError();

// no error when a call was made, but also already expected
handler.handleError(new Error('error 1'));
handler.match(() => true);
expect(() => {
handler.verify();
}).not.toThrowError();
});

it('throws if there is an outstanding error, including the number of them', () => {
const handler = TestBed.inject(MockErrorHandler);

// when multiple errors have not been expected
handler.handleError(new Error('error 1'));
handler.handleError(new Error('error 2'));
expect(() => {
handler.verify();
}).toThrowError('Expected no error(s), found 2');

// when SOME errors have already been expected, but not all
handler.match('error 2');
expect(() => {
handler.verify();
}).toThrowError('Expected no error(s), found 1');
});
});

describe('example from the docs', () => {
it('tracks errors', () => {
const ctx = new AngularContext();
ctx.run(() => {
// test something that is supposed to throw an error
ctx.inject(ErrorHandler).handleError(new Error('special message'));

// expect that it did
ctx.inject(MockErrorHandler).expectOne('special message');
});
});
});
});
120 changes: 120 additions & 0 deletions projects/ng-dev/src/lib/mock-error-handler.ts
@@ -0,0 +1,120 @@
import { ErrorHandler, Injectable, Provider } from '@angular/core';
import { isRegExp, isString, remove } from '@s-libs/micro-dash';
import { buildErrorMessage } from './utils';

type ErrorType = Parameters<ErrorHandler['handleError']>[0];
type Match = string | RegExp | ((error: ErrorType) => boolean);

/**
* An error handler to be used in tests, that keeps track of all errors it handles and allows for expectations to run on them.
*
* ```ts
* it('tracks errors', () => {
* const ctx = new AngularContext();
* ctx.run(() => {
* // test something that is supposed to throw an error
* ctx.inject(ErrorHandler).handleError(new Error('special message'));
*
* // expect that it did
* ctx.inject(MockErrorHandler).expectOne('special message');
* });
* });
* ```
*/
@Injectable({ providedIn: 'root' })
export class MockErrorHandler extends ErrorHandler {
/**
* Convenience method to put in a `provide` array, to override Angular's default error handler. Note that this is provided automatically by {@linkcode AngularContext}.
*
* ```ts
* TestBed.configureTestingModule({
* providers: [MockErrorHandler.createProvider()],
* });
* ```
*/
static createProvider(): Provider {
return { provide: ErrorHandler, useExisting: MockErrorHandler };
}

#errors: ErrorType[] = [];

/**
* In addition to tracking the error, this call's Angular's [ErrorHandler.handleError]{@linkcode https://angular.io/api/core/ErrorHandler#handleError}, prints the error to the console and may print additional information that could be helpful for finding the source of the error.
*/
override handleError(error: ErrorType): void {
super.handleError(error);
this.#errors.push(error);
}

/**
* Expect that a single error was handled that matches the given message or predicate, and return it. If no such error was handled, or more than one, fail with a message including `description`, if provided.
*/
expectOne(match: Match, description?: string): ErrorType {
const matches = this.match(match);
if (matches.length !== 1) {
throw new Error(
buildErrorMessage({
matchType: 'one matching',
itemType: 'error',
matches,
stringifiedUserInput: stringifyUserInput(match, description),
}),
);
}
return matches[0];
}

/**
* Expect that no errors were handled which match the given message or predicate. If a matching error was handled, fail with a message including `description`, if provided.
*/
expectNone(match: Match, description?: string): void {
const matches = this.match(match);
if (matches.length > 0) {
throw new Error(
buildErrorMessage({
matchType: 'zero matching',
itemType: 'error',
matches,
stringifiedUserInput: stringifyUserInput(match, description),
}),
);
}
}

/**
* Search for errors that match the given message or predicate, without any expectations.
*/
match(match: Match): ErrorType[] {
if (isString(match)) {
const message = match;
match = (error) => error?.message === message;
} else if (isRegExp(match)) {
const regex = match;
match = (error) => regex.test(error?.message);
}
return remove(this.#errors, match);
}

/**
* Verify that no unmatched errors are outstanding. If any are, fail with a message indicating which calls were not matched.
*/
verify() {
const count = this.#errors.length;
if (count > 0) {
throw new Error(`Expected no error(s), found ${count}`);
}
}
}

function stringifyUserInput(match: Match, description?: string): string {
if (!description) {
if (isString(match)) {
description = 'Match by string: ' + match;
} else if (isRegExp(match)) {
description = 'Match by regexp: ' + match;
} else {
description = 'Match by function: ' + match.name;
}
}
return description;
}

0 comments on commit d83353e

Please sign in to comment.