diff --git a/TODO.md b/TODO.md index f544eb0b..b31ecb66 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ - landing page to link to all API docs - coveralls - help may be here, to combine multiple coverage runs into one report: https://github.com/angular/angular-cli/issues/11268 -- see if typedoc can support that "lib" mode that would eliminate the need for @hidden yet +- Watch for when typedoc can support that "lib" mode that would eliminate the need for @hidden diff --git a/projects/integration/src/app/api-tests/ng-dev.spec.ts b/projects/integration/src/app/api-tests/ng-dev.spec.ts index c6499544..4be91932 100644 --- a/projects/integration/src/app/api-tests/ng-dev.spec.ts +++ b/projects/integration/src/app/api-tests/ng-dev.spec.ts @@ -1,6 +1,8 @@ import { AngularContext, + AsyncMethodController, ComponentContext, + TestCall, createSpyObject, expectCallsAndReset, expectSingleCallAndReset, @@ -15,10 +17,18 @@ describe('ng-dev', () => { expect(AngularContext).toBeDefined(); }); + it('has AsyncMethodController', () => { + expect(AsyncMethodController).toBeDefined(); + }); + it('has ComponentContext', () => { expect(ComponentContext).toBeDefined(); }); + it('has TestCall', () => { + expect(TestCall).toBeDefined(); + }); + it('has createSpyObject()', () => { expect(createSpyObject).toBeDefined(); }); diff --git a/projects/ng-dev/src/lib/spies/async-method-controller.spec.ts b/projects/ng-dev/src/lib/spies/async-method-controller.spec.ts new file mode 100644 index 00000000..0c9c8ca0 --- /dev/null +++ b/projects/ng-dev/src/lib/spies/async-method-controller.spec.ts @@ -0,0 +1,369 @@ +import { AngularContext } from '../test-context'; +import { AsyncMethodController } from './async-method-controller'; + +describe('AsyncMethodController', () => { + describe('constructor', () => { + it('allows the controlled method to be called immediately', () => { + // tslint:disable-next-line:no-unused-expression + new AsyncMethodController(navigator.clipboard, 'readText'); + + expect(() => { + navigator.clipboard.readText().then(); + }).not.toThrowError(); + }); + }); + + describe('.expectOne()', () => { + it('finds a matching method call', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + + const match = controller.expectOne((call) => call.args[0] === 'value 2'); + + expect(match.callInfo.args[0]).toEqual('value 2'); + }); + + describe('error message', () => { + it('throws an error when there is no match', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + navigator.clipboard.readText(); + + expect(() => { + controller.expectOne(() => false); + }).toThrowError( + 'Expected one matching call(s) for criterion "Match by function: ", found 0', + ); + }); + + it('throws an error when there have been no calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + expect(() => { + controller.expectOne(() => true); + }).toThrowError( + 'Expected one matching call(s) for criterion "Match by function: ", found 0', + ); + }); + + it('throws an error when there is more than one match', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + navigator.clipboard.readText(); + navigator.clipboard.readText(); + + expect(() => { + controller.expectOne(() => true); + }).toThrowError( + 'Expected one matching call(s) for criterion "Match by function: ", found 2', + ); + }); + }); + }); + + describe('.expectNone()', () => { + it('throws an error if any call matches', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + + expect(() => { + controller.expectNone((call) => call.args[0] === 'value 2'); + }).toThrowError( + 'Expected zero matching call(s) for criterion "Match by function: ", found 1', + ); + }); + + it('does not throw an error when no call matches', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + expect(() => { + controller.expectNone(() => false); + }).not.toThrowError(); + }); + + it('accepts an array of arguments to match against', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + + expect(() => { + controller.expectNone(['value 2']); + }).toThrowMatching((error: Error) => error.message.includes('found 1')); + }); + }); + + describe('.match()', () => { + it('finds matching method calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + navigator.clipboard.writeText('value 3'); + + const matches = controller.match((call) => call.args[0] !== 'value 2'); + + expect(matches.map((match) => match.callInfo.args[0])).toEqual( + jasmine.arrayWithExactContents(['value 1', 'value 3']), + ); + }); + + it('accepts an array of arguments to match against', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + navigator.clipboard.writeText('value 1'); + + const matches = controller.match(['value 1']); + + expect(matches.map((match) => match.callInfo.args[0])).toEqual( + jasmine.arrayWithExactContents(['value 1', 'value 1']), + ); + }); + + it('uses deep equality matching for the arguments shorthand', () => { + const controller = new AsyncMethodController(window, 'fetch'); + fetch('a fake url', { method: 'GET' }); + fetch('a fake url', { method: 'POST' }); + fetch('a fake url', { method: 'GET' }); + + const matches = controller.match(['a fake url', { method: 'GET' }]); + + expect(matches.map((match) => match.callInfo.args[1])).toEqual( + jasmine.arrayWithExactContents([{ method: 'GET' }, { method: 'GET' }]), + ); + }); + + it('remove the matching calls from future matching', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + + controller.match((call) => call.args[0] === 'value 2'); + + expect(controller.match(() => true).length).toBe(1); + }); + + it('returns an empty array when there have been no calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + const matches = controller.match(() => false); + expect(matches).toEqual([]); + }); + + it('can gracefully handle when no calls match', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + navigator.clipboard.readText(); + + const matches = controller.match(() => false); + + expect(matches).toEqual([]); + }); + }); + + describe('.verify()', () => { + it('does not throw an error when all calls have been expected', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + // no error when no calls were made at all + expect(() => { + controller.verify(); + }).not.toThrowError(); + + // no error when a call was made, but also already expected + navigator.clipboard.readText(); + controller.expectOne([]); + expect(() => { + controller.verify(); + }).not.toThrowError(); + }); + + it('throws an error if there is an outstanding call, including the number of open calls', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + + // when multiple calls have not been expected + navigator.clipboard.writeText('call 1'); + navigator.clipboard.writeText('call 2'); + expect(() => { + controller.verify(); + }).toThrowMatching((error: Error) => + error.message.includes('Expected no open call(s), found 2:'), + ); + + // when SOME calls have already been expected, but not all + controller.expectOne(['call 2']); + expect(() => { + controller.verify(); + }).toThrowMatching((error: Error) => + error.message.includes('Expected no open call(s), found 1:'), + ); + }); + + it('includes a nice representation of the outstanding calls in the error message', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('call 1'); + navigator.clipboard.writeText('call 2'); + + expect(() => { + controller.verify(); + }).toThrowMatching((error: Error) => + error.message.includes('\n ["call 1"]\n ["call 2"]'), + ); + }); + }); + + describe('.#ensureCallInfoIsSet()', () => { + it('correctly initializes TestCall objects even after others have been matched', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + + // make call1, causing: + // testCalls: [call1] + // spy.calls: [call1] + navigator.clipboard.writeText('call1'); + + // match call1, causing: + // testCalls: [] + // spy.calls: [call1] + controller.expectOne(() => true); + + // make call2, causing: + // testCalls: [call2] + // spy.calls: [call1, call2] + navigator.clipboard.writeText('call2'); + + // try matching call2 + const testCall = controller.expectOne(() => true); + expect(testCall.callInfo.args[0]).toBe('call2'); + }); + }); + + describe('.#buildErrorMessage()', () => { + it('includes the given description when throwing an error', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + expect(() => { + controller.expectOne(() => true, 'show this'); + }).toThrowMatching((error: Error) => + error.message.includes('for criterion "show this"'), + ); + }); + + it('includes the name of the match function when throwing an error, when no description is provided', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + + expect(() => { + controller.expectOne(nofindy); + }).toThrowMatching((error: Error) => + error.message.includes('criterion "Match by function: nofindy"'), + ); + + function nofindy(): boolean { + return false; + } + }); + + it('formats the error message nicely when using the arguments shorthand', () => { + const controller = new AsyncMethodController(window, 'fetch'); + + expect(() => { + controller.expectOne(['some url', { method: 'GET' }]); + }).toThrowMatching((error: Error) => + error.message.includes( + 'for criterion "Match by arguments: ["some url",{"method":"GET"}]"', + ), + ); + }); + + it('includes the number of matches found', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'writeText', + ); + navigator.clipboard.writeText('value 1'); + navigator.clipboard.writeText('value 2'); + navigator.clipboard.writeText('value 3'); + + expect(() => { + controller.expectOne((call) => call.args[0] !== 'value 2'); + }).toThrowMatching((error: Error) => error.message.includes('found 2')); + }); + }); + + describe('examples from the docs', () => { + it('can paste', () => { + const clipboard = navigator.clipboard; + const ctx = new AngularContext(); + + // mock the browser API for pasting + const controller = new AsyncMethodController(clipboard, 'readText', { + ctx, + }); + ctx.run(() => { + // BEGIN production code that copies to the clipboard + let pastedText: string; + clipboard.readText().then((text) => { + pastedText = text; + }); + // END production code that copies to the clipboard + + // mock the behavior when the user denies access to the clipboard + controller.expectOne([]).flush('mock clipboard contents'); + + // BEGIN expect the correct results after a successful copy + expect(pastedText!).toBe('mock clipboard contents'); + // END expect the correct results after a successful copy + }); + }); + }); +}); diff --git a/projects/ng-dev/src/lib/spies/async-method-controller.ts b/projects/ng-dev/src/lib/spies/async-method-controller.ts new file mode 100644 index 00000000..c0b32604 --- /dev/null +++ b/projects/ng-dev/src/lib/spies/async-method-controller.ts @@ -0,0 +1,199 @@ +import { Deferred } from '@s-libs/js-core'; +import { isEqual, nth, remove } from '@s-libs/micro-dash'; +import { AngularContext } from '../test-context'; +import { TestCall } from './test-call'; + +/** @hidden */ +type AsyncFunc = (...args: any[]) => Promise; + +/** @hidden */ +type Match< + WrappingObject, + FunctionName extends AsyncMethodKeys +> = + | Parameters + | ((callInfo: jasmine.CallInfo) => boolean); + +/** @hidden */ +type AsyncMethodKeys = { + [k in keyof T]: T[k] extends AsyncFunc ? k : never; +}[keyof T]; + +/** + * Controller to be used in tests, that allows for mocking and flushing any asynchronous function. For example, to mock the browser's paste functionality: + * + * ```ts + * it('can paste', () => { + * const clipboard = navigator.clipboard; + * const ctx = new AngularContext(); + * + * // mock the browser API for pasting + * const controller = new AsyncMethodController(clipboard, 'readText', { + * context: ctx, + * }); + * ctx.run(() => { + * // BEGIN production code that copies to the clipboard + * let pastedText: string; + * clipboard.readText().then((text) => { + * pastedText = text; + * }); + * // END production code that copies to the clipboard + * + * // mock the behavior when the user denies access to the clipboard + * controller.expectOne([]).flush('mock clipboard contents'); + * + * // BEGIN expect the correct results after a successful copy + * expect(pastedText!).toBe('mock clipboard contents'); + * // END expect the correct results after a successful copy + * }); + * }); + * ``` + */ +export class AsyncMethodController< + WrappingObject, + FunctionName extends AsyncMethodKeys +> { + #spy: jasmine.Spy; + #testCalls: TestCall[] = []; + + /** + * Optionally provide `ctx` to automatically trigger promise handlers and changed detection after calling `flush()` or `error()`. This is the normal production behavior of asynchronous browser APIs. However, if the function you are stubbing is not patched by zone.js, change detection would not run automatically, in which case you many not want to pass this parameter. See the list of functions that zone.js patches [here](https://github.com/angular/angular/blob/master/packages/zone.js/STANDARD-APIS.md). + */ + constructor( + obj: WrappingObject, + methodName: FunctionName, + { ctx = undefined as AngularContext | undefined } = {}, + ) { + // Note: it wasn't immediately clear how avoid `any` in this constructor, and this will be invisible to users. So I gave up. (For now.) + this.#spy = spyOn(obj, methodName as any) as any; + this.#spy.and.callFake((() => { + const deferred = new Deferred(); + this.#testCalls.push(new TestCall(deferred, ctx)); + return deferred.promise; + }) as any); + } + + /** + * Expect that a single call has been made which matches the given parameters or predicate, and return its mock. If no such call has been made, or more than one such call has been made, fail with an error message including `description`, if provided. + */ + expectOne( + match: Match, + description?: string, + ): TestCall { + const matches = this.match(match); + if (matches.length !== 1) { + throw new Error( + this.buildErrorMessage({ + matchType: 'one matching', + matches, + stringifiedUserInput: this.stringifyUserInput(match, description), + }), + ); + } + return matches[0]; + } + + /** + * Expect that no calls have been made which match the given parameters or predicate. If a matching call has been made, fail with an error message including `description`, if provided. + */ + expectNone( + match: Match, + description?: string, + ): void { + const matches = this.match(match); + if (matches.length > 0) { + throw new Error( + this.buildErrorMessage({ + matchType: 'zero matching', + matches, + stringifiedUserInput: this.stringifyUserInput(match, description), + }), + ); + } + } + + /** + * Search for calls that match the given parameters or predicate, without any expectations. + */ + match( + match: Match, + ): TestCall[] { + this.ensureCallInfoIsSet(); + const filterFn = Array.isArray(match) + ? this.makeArgumentMatcher(match) + : match; + return remove(this.#testCalls, (testCall) => filterFn(testCall.callInfo)); + } + + /** + * Verify that no unmatched calls are outstanding. If any calls are outstanding, fail with an error message indicating which calls were not handled. + */ + verify(): void { + if (this.#testCalls.length) { + this.ensureCallInfoIsSet(); + let message = + this.buildErrorMessage({ + matchType: 'no open', + matches: this.#testCalls, + }) + ':'; + for (const testCall of this.#testCalls) { + message += `\n ${stringifyArgs(testCall.callInfo.args)}`; + } + throw new Error(message); + } + } + + private ensureCallInfoIsSet(): void { + for (let i = 1; i <= this.#testCalls.length; ++i) { + const testCall = nth(this.#testCalls, -i); + if (testCall.callInfo) { + return; + } + + testCall.callInfo = nth(this.#spy.calls.all(), -i); + } + } + + private makeArgumentMatcher( + args: Parameters, + ): (callInfo: jasmine.CallInfo) => boolean { + return (callInfo: jasmine.CallInfo) => + isEqual(callInfo.args, args); + } + + private buildErrorMessage({ + stringifiedUserInput, + matchType, + matches, + }: { + stringifiedUserInput?: string; + matchType: string; + matches: TestCall[]; + }): string { + let message = `Expected ${matchType} call(s)`; + if (stringifiedUserInput) { + message += ` for criterion "${stringifiedUserInput}"`; + } + message += `, found ${matches.length}`; + return message; + } + + private stringifyUserInput( + match: Match, + description?: string, + ): string { + if (!description) { + if (Array.isArray(match)) { + description = 'Match by arguments: ' + stringifyArgs(match); + } else { + description = 'Match by function: ' + match.name; + } + } + return description; + } +} + +/** @hidden */ +function stringifyArgs(args: any[]): string { + return JSON.stringify(args); +} diff --git a/projects/ng-dev/src/lib/spies/index.ts b/projects/ng-dev/src/lib/spies/index.ts index 4922ea58..e5c12597 100644 --- a/projects/ng-dev/src/lib/spies/index.ts +++ b/projects/ng-dev/src/lib/spies/index.ts @@ -1,3 +1,5 @@ +export { AsyncMethodController } from './async-method-controller'; export { createSpyObject } from './create-spy-object'; export { expectCallsAndReset } from './expect-calls-and-reset'; export { expectSingleCallAndReset } from './expect-single-call-and-reset'; +export { TestCall } from './test-call'; diff --git a/projects/ng-dev/src/lib/spies/test-call.spec.ts b/projects/ng-dev/src/lib/spies/test-call.spec.ts new file mode 100644 index 00000000..0e2b438b --- /dev/null +++ b/projects/ng-dev/src/lib/spies/test-call.spec.ts @@ -0,0 +1,103 @@ +import { Component, Input } from '@angular/core'; +import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; +import { noop } from '@s-libs/micro-dash'; +import { ComponentContext } from '../test-context'; +import { AsyncMethodController } from './async-method-controller'; +import { expectSingleCallAndReset } from './expect-single-call-and-reset'; + +describe('TestCall', () => { + @Component({ template: 'Hello, {{name}}!' }) + class TestComponent { + @Input() name!: string; + } + + class TestComponentContext extends ComponentContext { + protected componentType = TestComponent; + } + + describe('.callInfo', () => { + it('is populated with a jasmine.CallInfo object', () => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + navigator.clipboard.readText(); + + const callInfo = controller.expectOne([]).callInfo; + + // We'd love to test that this is just an "instanceof jasmine.CallInfo", but that's not really a thing. So we'll approximate it by ensuring a couple properties exist. + expect(callInfo.returnValue).toBeInstanceOf(Promise); + expect(callInfo.object).toBe(navigator.clipboard); + }); + }); + + describe('.flush()', () => { + it('causes the call to be fulfilled with the given value', fakeAsync(() => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + const spy = jasmine.createSpy(); + navigator.clipboard.readText().then(spy); + const testCall = controller.match(() => true)[0]; + + testCall.flush('the clipboard text'); + flushMicrotasks(); + + expectSingleCallAndReset(spy, 'the clipboard text'); + })); + + it('triggers change detection if the AsyncMethodController was passed a context', () => { + const ctx = new TestComponentContext(); + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + { ctx }, + ); + + ctx.run(() => { + navigator.clipboard.readText(); + const testCall = controller.expectOne([]); + + ctx.fixture.componentInstance.name = 'Changed Guy'; + testCall.flush('this is the clipboard content'); + expect(ctx.fixture.nativeElement.textContent).toContain('Changed Guy'); + }); + }); + }); + + describe('.error()', () => { + it('causes the call to be rejected with the given reason', fakeAsync(() => { + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + ); + const spy = jasmine.createSpy(); + navigator.clipboard.readText().catch(spy); + const testCall = controller.match(() => true)[0]; + + testCall.error('some problem'); + flushMicrotasks(); + + expectSingleCallAndReset(spy, 'some problem'); + })); + + it('triggers change detection if the AsyncMethodController was passed a context', () => { + const ctx = new TestComponentContext(); + const controller = new AsyncMethodController( + navigator.clipboard, + 'readText', + { ctx }, + ); + + ctx.run(() => { + navigator.clipboard.readText().catch(noop); + const testCall = controller.expectOne([]); + + ctx.fixture.componentInstance.name = 'Changed Guy'; + testCall.error('permission denied'); + expect(ctx.fixture.nativeElement.textContent).toContain('Changed Guy'); + }); + }); + }); +}); diff --git a/projects/ng-dev/src/lib/spies/test-call.ts b/projects/ng-dev/src/lib/spies/test-call.ts new file mode 100644 index 00000000..346bcdbb --- /dev/null +++ b/projects/ng-dev/src/lib/spies/test-call.ts @@ -0,0 +1,32 @@ +import { Deferred } from '@s-libs/js-core'; +import { PromiseType } from 'utility-types'; +import { AngularContext } from '../test-context'; + +/** + * A mock method call that was made and is ready to be answered. This interface allows access to the underlying jasmine.CallInfo, and allows resolving or rejecting the asynchronous call's result. + */ +export class TestCall { + /** The underlying jasmine call object */ + callInfo!: jasmine.CallInfo; + + constructor( + private deferred: Deferred>>, + private ctx?: AngularContext, + ) {} + + /** + * Resolve the call with the given value. + */ + flush(value: PromiseType>): void { + this.deferred.resolve(value); + this.ctx?.tick(); + } + + /** + * Reject the call with the given reason. + */ + error(reason: any): void { + this.deferred.reject(reason); + this.ctx?.tick(); + } +} diff --git a/projects/ng-dev/src/typing-tests/async-method-controller.dts-spec.ts b/projects/ng-dev/src/typing-tests/async-method-controller.dts-spec.ts new file mode 100644 index 00000000..b1447774 --- /dev/null +++ b/projects/ng-dev/src/typing-tests/async-method-controller.dts-spec.ts @@ -0,0 +1,33 @@ +import { AsyncMethodController } from '../lib/spies'; + +export type Evaluate = T extends infer I ? { [K in keyof I]: T[K] } : never; + +const writeController = new AsyncMethodController( + navigator.clipboard, + 'writeText', +); +const readController = new AsyncMethodController( + navigator.clipboard, + 'readText', +); + +// $ExpectType [data: string] | {} +type writeExpectOneMatchType = Evaluate< + Parameters[0] +>; +writeController.expectOne( + ( + // $ExpectType CallInfo<(data: string) => Promise> + callInfo, + ) => true, +); +// $ExpectType [] | {} +type readExpectOneMatchType = Evaluate< + Parameters[0] +>; +readController.expectOne( + ( + // $ExpectType CallInfo<() => Promise> + callInfo, + ) => true, +); diff --git a/projects/ng-dev/src/typing-tests/test-call.dts-spec.ts b/projects/ng-dev/src/typing-tests/test-call.dts-spec.ts new file mode 100644 index 00000000..2e9e606b --- /dev/null +++ b/projects/ng-dev/src/typing-tests/test-call.dts-spec.ts @@ -0,0 +1,23 @@ +import { AsyncMethodController } from '../lib/spies'; + +const writeController = new AsyncMethodController( + navigator.clipboard, + 'writeText', +); +const readController = new AsyncMethodController( + navigator.clipboard, + 'readText', +); + +const writeTestCall = writeController.expectOne(['something I copied']); +const readTestCall = readController.expectOne([]); + +// $ExpectType [data: string] +const writeArgs = writeTestCall.callInfo.args; +// $ExpectType [] +const readArgs = readTestCall.callInfo.args; + +// $ExpectType (value: void) => void +const writeFlush = writeTestCall.flush; +// $ExpectType (value: string) => void +const readFlush = readTestCall.flush;