From 6ad10980edd332696b7d416d00ce92b88f2189f3 Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Mon, 20 Aug 2018 13:37:06 -0600 Subject: [PATCH 1/2] Add Stub class. --- src/test/stub.ts | 223 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 src/test/stub.ts diff --git a/src/test/stub.ts b/src/test/stub.ts new file mode 100644 index 000000000000..efff32a23b86 --- /dev/null +++ b/src/test/stub.ts @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { assert } from 'chai'; + +/** + * StubCall records the name of a called function and the passed args. + */ +export class StubCall { + constructor( + // Funcname is the name of the function that was called. + public readonly funcName: string, + // Args is the set of arguments passed to the function. They are + // in the same order as the function's parameters. + // tslint:disable-next-line:no-any + public readonly args: any[]) {} +} + +/** + * Stub is used in testing to stand in for some other value, to record + * all calls to stubbed methods/functions, and to allow users to set the + * values that are returned from those calls. Stub is intended to be + * an attribute of a class that will define the methods to track: + * + * class stubConn { + * public returnResponse: string = []; + * + * constructor( + * public stub: Stub = new Stub()) {}; + * + * public send(request: string): string { + * this.stub.addCall('send', request); + * this.stub.maybeErr(); + * return this.returnResponse; + * } + * } + * + * As demonstrated in the example, by supporting a stub argument, a + * single Stub may be shared between multiple stubs. This allows you + * to capture the calls of all stubs in absolute order. + * + * Exceptions are set through setErrors(). Set them to the errors (or + * lack thereof, i.e. null) you want raised. The + * `maybeErr` method raises the set exceptions (if any) in sequence, + * falling back to null when the sequence is exhausted. Thus each + * stubbed method should call `maybeErr` to get its exception. + * `popNoError` is an alternative if the method should never throw. + * + * To validate calls made to the stub in a test call the CheckCalls (or + * CheckCall) method: + * + * stub.checkCalls([ + * new StubCall('send', [ + * expected + * ]) + * ]); + * + * s.stub.CheckCall(0, 'send', expected); + * + * Not only is Stub useful for building an interface implementation to + * use in testing (e.g. a network API client), it is also useful in + * regular function patching situations: + * + * class MyStub { + * public stub: Stub; + * + * public someFunc(arg: any) { + * this.stub.addCall('someFunc', arg) + * this.stub.maybeErr(); + * } + * } + * + * const s = new MyStub(); + * mod.func = s.someFunc; // monkey-patch + * + * This allows for easily monitoring the args passed to the patched + * func, as well as controlling the return value from the func in a + * clean manner (by simply setting the correct field on the stub). + */ +// Based on: https://github.com/juju/testing/blob/master/stub.go +export class Stub { + // calls is the list of calls that have been registered on the stub + // (i.e. made on the stub's methods), in the order that they were + // made. + private _calls: StubCall[]; + // errors holds the list of exceptions to use for successive calls + // to methods that throw one. Each call pops the next error off the + // list. An empty list (the default) implies a nil error. null may + // be precede actual errors in the list, which means that the first + // calls will succeed, followed by the failure. All this is + // facilitated through the maybeErr method. + private _errors: (Error | null)[]; + + constructor() { + this._calls = []; + this._errors = []; + } + + public get calls(): StubCall[] { + return this._calls.slice(0); // a copy + } + + public get errors(): (Error | null)[] { + return this._errors.slice(0); // a copy + } + + //======================= + // before execution: + + /* + * setErrors sets the sequence of exceptions for the stub. Each call + * to maybeErr (thus each stub method call) pops an error off the + * front. So frontloading null here will allow calls to pass, + * followed by a failure. + */ + public setErrors(...errors: (Error | null)[]) { + this._errors = errors; + } + + //======================= + // during execution: + + // addCall records a stubbed function call for later inspection + // using the checkCalls method. All stubbed functions should call + // addCall. + // tslint:disable-next-line:no-any + public addCall(name: string, ...args: any[]) { + this._calls.push(new StubCall(name, args)); + } + + /* + * ResetCalls erases the calls recorded by this Stub. + */ + public resetCalls() { + this._calls = []; + } + + /* + * maybeErr returns the error that should be returned on the nth + * call to any method on the stub. It should be called for the + * error return in all stubbed methods. + */ + public maybeErr() { + if (this._errors.length === 0) { + return; + } + const err = this._errors[0]; + this._errors.shift(); + if (err !== null) { + throw err; + } + } + + /* + * popNoErr pops off the next error without returning it. If the + * error is not null then popNoErr will fail. + * + * popNoErr is useful in stub methods that do not return an error. + */ + public popNoErr() { + if (this._errors.length === 0) { + return; + } + const err = this._errors[0]; + this._errors.shift(); + if (err !== null) { + assert.fail(null, err, 'the next err was unexpectedly not null'); + } + } + + //======================= + // after execution: + + /* + * checkCalls verifies that the history of calls on the stub's + * methods matches the expected calls. + */ + public checkCalls(expected: StubCall[]) { + assert.deepEqual(this._calls, expected); + } + + // tslint:disable-next-line:no-suspicious-comment + // TODO: Add checkCallsUnordered? + // tslint:disable-next-line:no-suspicious-comment + // TODO: Add checkCallsSubset? + + /* + * checkCall checks the recorded call at the given index against the + * provided values. i If the index is out of bounds then the check + * fails. + */ + // tslint:disable-next-line:no-any + public checkCall(index: number, funcName: string, ...args: any[]) { + assert.isBelow(index, this._calls.length); + const expected = new StubCall(funcName, args); + assert.deepEqual(this._calls[index], expected); + } + + /* + * checkCallNames verifies that the in-order list of called method + * names matches the expected calls. + */ + public checkCallNames(...expected: string[]) { + const names: string[] = []; + for (const call of this._calls) { + names.push(call.funcName); + } + assert.deepEqual(names, expected); + } + + // checkNoCalls verifies that none of the stub's methods have been + // called. + public checkNoCalls() { + assert.equal(this._calls.length, 0); + } + + // checkErrors verifies that the list of unused exceptions is empty. + public checkErrors() { + assert.equal(this._errors.length, 0); + } +} From aeb8625593b81ee216880521c826b3d68c4615dc Mon Sep 17 00:00:00 2001 From: Eric Snow Date: Wed, 12 Jun 2019 15:30:49 -0600 Subject: [PATCH 2/2] Simplify! --- src/test/doubles.ts | 341 ++++++++++++++++++++++++++++++++++++++++++++ src/test/stub.ts | 223 ----------------------------- 2 files changed, 341 insertions(+), 223 deletions(-) create mode 100644 src/test/doubles.ts delete mode 100644 src/test/stub.ts diff --git a/src/test/doubles.ts b/src/test/doubles.ts new file mode 100644 index 000000000000..069f706ee492 --- /dev/null +++ b/src/test/doubles.ts @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/* +There is a variety of testing doubles that may be used in place of +full-featured objects during tests, with the objective of isolating +the code under test. Here are the most common ones: + + * dummy - a non-functional placeholder + * stub - has hard-coded return values + * spy - tracks calls + * mock - has expectations about calls and verifies them + * fake - mimics behavior (useful in functional/integration tests) + +(See https://www.martinfowler.com/bliki/TestDouble.html.) + +In this module you will find tools to facilitate some of those roles. +In particular, the "Stubbed" class is a mixture of stub and spy, with +just a hint of mock. Notably, it: + + * stands in as for other values of a given type/interface + * records all calls to stubbed methods + * allows raising a pre-defined exception from a method + * returns a pre-defined static value (for methods that return a value) + +The goal here is the following properties: + + * easy to use + * easy to read and understand what's happening + * explicit about what is implemented + +The "stubs" here achieve that because they look like simple, regular +interface implementations. + +Here are some of the benefits of using them: + + * behavior is obvious + * encourages keeping interfaces tight to needs + * stubs will nearly always be defined in the same file as the tests + +Here's an example of use: + + import { Stubbed, TestTracking } from '../doubles'; + + interface Conn { + send(request: string): Promise; + recv(): Promise; + count(): number; + close(): void; + } + type RunFunc = (conn: Conn) => Promise; + + suite('...', () => { + let tracking : TestTracking; + setup(() => { + tracking = new TestTracking(); + }); + + test('...', async () => { + tracking.errors.setAll( + null, + null, + null, + null, + Error('oops'), + null, + Error('already closed!') + ); + const conn = new StubConn(tracking); + + await run(conn); + + tracking.verifyAll([ + ['count', []], + ['send', ['']], + ['recv', []], + ['send', ['']], + ['recv', []], + ['close', []], + ['count', []] + ]); + }); + }); + + class StubConn { + public returnSend?: number; + public returnRecv?: string[]; // One for each call. + public returnCount?: number; + + public async send(request: string): Promise { + this._calls.add('send', request); + this._errors.tryNext(); + return Promise.resolve(this.returnSend); + } + + public async recv(): Promise { + return Promise.resolve( + this._handleMethod('recv') as string); + } + + public count(): number { + return this._handleMethod('count') as number; + } + + public close() { + //this._handleMethod('close'); + this._calls.add('close'); + this._errors.tryNext(); + } + } + +This module is available as an alternative to "mocks" for cases where +the tests do not need the extra complexity that comes with "mocks". +*/ + +'use strict'; + +// tslint:disable:max-classes-per-file + +import { assert, expect } from 'chai'; + +/** + * Call records the name of a called function and the passed args. + */ +export type Call = [ + /** + * This is the name of the function that was called. + */ + string, + /** + * This is the list of arguments passed to the function. They are + * in the same order as the function's parameters. + */ + // tslint:disable-next-line:no-any + any[] // args +]; + +/** + * The list of calls made during a test. + */ +export class Calls { + // The list of calls in the order in which they were made. + private calls: Call[]; + + constructor() { + this.calls = []; + } + + /** + * Return a copy of the raw calls, in the original order. + */ + public snapshot(): Call[] { + return this.calls.slice(0); // a copy + } + + /** + * Clear the recorded calls. + */ + public reset() { + this.calls = []; + } + + //======================= + // during execution: + + /** + * Record a call for later inspection (e.g. via checkCalls()). + * + * This will be called at the beginning of each stubbed-out method. + */ + // tslint:disable-next-line:no-any + public add(name: string, ...args: any[]) { + this.calls.push([name, args]); + } + + //======================= + // after execution: + + /** + * Verify that the history of calls matches expectations. + */ + public check(expected: Call[]) { + assert.deepEqual(this.calls, expected); + } + + /* + Posible other methods: + * checkCallsUnordered + * checkCallsSubset + * checkCall (by index) + * checkCallNames + * checkNoCalls + */ +} + +/** + * The errors to throw accross all tracked calls. + */ +export class Errors { + private errors: (Error | null)[]; + + constructor() { + this.errors = []; + } + + /** + * Return a copy of the remaining errors. + */ + public snapshot(): (Error | null)[] { + return this.errors.slice(0); // a copy + } + + //======================= + // before execution: + + /* + * Set the sequence of "errors" to match to calls. + * + * Each item is either an error or null corresponding to an + * expected call, where null represents that there is no error for + * that call. An empty list (the default) indicates that no further + * calls will fail. + * + * Each call to tryNext() popa off the next "error" from the front. + * So the following: + * + * errors.setAll( + * null, + * null, + * null, + * new Error('oops') + * ); + * + * Means that no error will be thrown for the first 3 calls, the + * fourth call will throw, and then any further calls will not throw + * an error. + */ + public setAll(...errors: (Error | null)[]) { + this.errors = errors; + } + + //======================= + // during execution: + + /* + * Throw the next error, if there is one. + * + * The error corresponds to the nth call (across all tracked calls). + * So all stubbed-out methods must call this method. + */ + public tryNext() { + const err = this.errors.shift(); + if (err !== null && err !== undefined) { + throw err; + } + } + + //======================= + // after execution: + + /** + * Verify that the list of unused errors is empty. + */ + public check() { + assert.equal(this.errors.length, 0); + } +} + +/** + * The testing state that can be shared by multiple Stubbed. + */ +export class TestTracking { + constructor( + public readonly calls: Calls = new Calls(), + public readonly errors: Errors = new Errors() + ) { } + + /** + * Check the calls and the errors. + */ + public verifyAll(expected: Call[]) { + this.calls.check(expected); + this.errors.check(); + } +} + +/** + * The base class for stubbed-out classes that implement an API. + * + * Subclasses must add an optional public "return*" property + * corresponding to each method that returns a value. For example, + * a subclass with a "runAll" method that returns a string would define + * the following property: + * + * public returnRunAll?: string; + * + * All implemented API methods (including getters, etc.) should trigger + * tracking for every call. Example: + * + * this._calls.add('methodName', arg1, arg2); + * this._errors.tryNext(); + * + * This can also be achieved by calling "this._handleMethod()", which + * also looks up the appropriate return value. + * + * All properties of this base class have "underscore" names to avoid + * possible conflicts with the API a subclass is implementing. + */ +export class Stubbed { + protected readonly _calls: Calls; + protected readonly _errors: Errors; + constructor(tracking: TestTracking) { + this._calls = tracking.calls; + this._errors = tracking.errors; + } + + /** + * Subclasses use this method to do the typical stub method + * operations. If there is a corresponding "return*" property for + * the method then that value gets checked and returned. The caller + * is responsible for casting the result to the appropriate type + * (and calling Promise.resolve() if async). + */ + // tslint:disable-next-line:no-any + protected _handleMethod(method: string, ...args: any[]): any { + this._calls.add(method, ...args); + this._errors.tryNext(); + + // Deal with the return value. + const prop = `return${method[0].toUpperCase()}${method.slice(1)}`; + if (!this.hasOwnProperty(prop)) { + return; + } + + const notSet = undefined; + // tslint:disable-next-line:no-any + const val = (this as any)[prop]; + expect(val).to.not.equal(notSet, `return var ${prop} not set`); + return val; + } +} diff --git a/src/test/stub.ts b/src/test/stub.ts deleted file mode 100644 index efff32a23b86..000000000000 --- a/src/test/stub.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import { assert } from 'chai'; - -/** - * StubCall records the name of a called function and the passed args. - */ -export class StubCall { - constructor( - // Funcname is the name of the function that was called. - public readonly funcName: string, - // Args is the set of arguments passed to the function. They are - // in the same order as the function's parameters. - // tslint:disable-next-line:no-any - public readonly args: any[]) {} -} - -/** - * Stub is used in testing to stand in for some other value, to record - * all calls to stubbed methods/functions, and to allow users to set the - * values that are returned from those calls. Stub is intended to be - * an attribute of a class that will define the methods to track: - * - * class stubConn { - * public returnResponse: string = []; - * - * constructor( - * public stub: Stub = new Stub()) {}; - * - * public send(request: string): string { - * this.stub.addCall('send', request); - * this.stub.maybeErr(); - * return this.returnResponse; - * } - * } - * - * As demonstrated in the example, by supporting a stub argument, a - * single Stub may be shared between multiple stubs. This allows you - * to capture the calls of all stubs in absolute order. - * - * Exceptions are set through setErrors(). Set them to the errors (or - * lack thereof, i.e. null) you want raised. The - * `maybeErr` method raises the set exceptions (if any) in sequence, - * falling back to null when the sequence is exhausted. Thus each - * stubbed method should call `maybeErr` to get its exception. - * `popNoError` is an alternative if the method should never throw. - * - * To validate calls made to the stub in a test call the CheckCalls (or - * CheckCall) method: - * - * stub.checkCalls([ - * new StubCall('send', [ - * expected - * ]) - * ]); - * - * s.stub.CheckCall(0, 'send', expected); - * - * Not only is Stub useful for building an interface implementation to - * use in testing (e.g. a network API client), it is also useful in - * regular function patching situations: - * - * class MyStub { - * public stub: Stub; - * - * public someFunc(arg: any) { - * this.stub.addCall('someFunc', arg) - * this.stub.maybeErr(); - * } - * } - * - * const s = new MyStub(); - * mod.func = s.someFunc; // monkey-patch - * - * This allows for easily monitoring the args passed to the patched - * func, as well as controlling the return value from the func in a - * clean manner (by simply setting the correct field on the stub). - */ -// Based on: https://github.com/juju/testing/blob/master/stub.go -export class Stub { - // calls is the list of calls that have been registered on the stub - // (i.e. made on the stub's methods), in the order that they were - // made. - private _calls: StubCall[]; - // errors holds the list of exceptions to use for successive calls - // to methods that throw one. Each call pops the next error off the - // list. An empty list (the default) implies a nil error. null may - // be precede actual errors in the list, which means that the first - // calls will succeed, followed by the failure. All this is - // facilitated through the maybeErr method. - private _errors: (Error | null)[]; - - constructor() { - this._calls = []; - this._errors = []; - } - - public get calls(): StubCall[] { - return this._calls.slice(0); // a copy - } - - public get errors(): (Error | null)[] { - return this._errors.slice(0); // a copy - } - - //======================= - // before execution: - - /* - * setErrors sets the sequence of exceptions for the stub. Each call - * to maybeErr (thus each stub method call) pops an error off the - * front. So frontloading null here will allow calls to pass, - * followed by a failure. - */ - public setErrors(...errors: (Error | null)[]) { - this._errors = errors; - } - - //======================= - // during execution: - - // addCall records a stubbed function call for later inspection - // using the checkCalls method. All stubbed functions should call - // addCall. - // tslint:disable-next-line:no-any - public addCall(name: string, ...args: any[]) { - this._calls.push(new StubCall(name, args)); - } - - /* - * ResetCalls erases the calls recorded by this Stub. - */ - public resetCalls() { - this._calls = []; - } - - /* - * maybeErr returns the error that should be returned on the nth - * call to any method on the stub. It should be called for the - * error return in all stubbed methods. - */ - public maybeErr() { - if (this._errors.length === 0) { - return; - } - const err = this._errors[0]; - this._errors.shift(); - if (err !== null) { - throw err; - } - } - - /* - * popNoErr pops off the next error without returning it. If the - * error is not null then popNoErr will fail. - * - * popNoErr is useful in stub methods that do not return an error. - */ - public popNoErr() { - if (this._errors.length === 0) { - return; - } - const err = this._errors[0]; - this._errors.shift(); - if (err !== null) { - assert.fail(null, err, 'the next err was unexpectedly not null'); - } - } - - //======================= - // after execution: - - /* - * checkCalls verifies that the history of calls on the stub's - * methods matches the expected calls. - */ - public checkCalls(expected: StubCall[]) { - assert.deepEqual(this._calls, expected); - } - - // tslint:disable-next-line:no-suspicious-comment - // TODO: Add checkCallsUnordered? - // tslint:disable-next-line:no-suspicious-comment - // TODO: Add checkCallsSubset? - - /* - * checkCall checks the recorded call at the given index against the - * provided values. i If the index is out of bounds then the check - * fails. - */ - // tslint:disable-next-line:no-any - public checkCall(index: number, funcName: string, ...args: any[]) { - assert.isBelow(index, this._calls.length); - const expected = new StubCall(funcName, args); - assert.deepEqual(this._calls[index], expected); - } - - /* - * checkCallNames verifies that the in-order list of called method - * names matches the expected calls. - */ - public checkCallNames(...expected: string[]) { - const names: string[] = []; - for (const call of this._calls) { - names.push(call.funcName); - } - assert.deepEqual(names, expected); - } - - // checkNoCalls verifies that none of the stub's methods have been - // called. - public checkNoCalls() { - assert.equal(this._calls.length, 0); - } - - // checkErrors verifies that the list of unused exceptions is empty. - public checkErrors() { - assert.equal(this._errors.length, 0); - } -}