diff --git a/README.md b/README.md index a2b0a603..ec1ef0c3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ console.log(foo.bar(23)); // 'I am strong!' - [Argument matchers](#argument-matchers) - [Mock options](#mock-options) - [Strictness](#strictness) + - [Exact params](#exact-params) - [Concrete matcher](#concrete-matcher) - [Defaults](#defaults) - [FAQ](#faq) @@ -367,6 +368,38 @@ superStrictFoo.bar; superStrictFoo.bar(42); ``` +#### Exact params + +By default, function/method expectations will allow more arguments to be received than expected. Since the expectations are type safe, the TypeScript compiler will never allow expecting less arguments than required. Unspecified optional arguments will be considered ignored, as if they've been replaced with [argument matchers](#argument-matchers). + +```typescript +import { mock } from 'strong-mock'; + +const fn = mock<(value?: number) => number>(); + +when(() => fn()).thenReturn(42).twice(); + +// Since the expectation doesn't expect any arguments, +// both of the following are fine +console.log(fn()); // 42 +console.log(fn(1)); // 42 +``` + +If you're not using TypeScript, or you want to be super strict, you can set `exactParams: true` when creating a mock, or via [setDefaults](#defaults). + +```typescript +import { mock } from 'strong-mock'; + +const fn = mock<(optionalValue?: number) => number>({ + exactParams: true +}); + +when(() => fn()).thenReturn(42).twice(); + +console.log(fn()); // 42 +console.log(fn(1)); // throws +``` + #### Concrete matcher You can set the matcher that will be used in expectations with concrete values e.g. `42` or `{ foo: "bar" }`. Passing in a [matcher argument](#argument-matchers) will always take priority. diff --git a/src/errors.spec.ts b/src/errors.spec.ts index 67cd5771..df7b9c43 100644 --- a/src/errors.spec.ts +++ b/src/errors.spec.ts @@ -30,7 +30,7 @@ describe('errors', () => { spyExpectationFactory ); - pendingExpectation.start(SM.instance(repo), SM.instance(matcher)); + pendingExpectation.start(SM.instance(repo), SM.instance(matcher), false); pendingExpectation.args = [1, 2, 3]; pendingExpectation.property = 'bar'; @@ -47,7 +47,7 @@ describe('errors', () => { spyExpectationFactory ); - pendingExpectation.start(SM.instance(repo), SM.instance(matcher)); + pendingExpectation.start(SM.instance(repo), SM.instance(matcher), false); pendingExpectation.args = undefined; pendingExpectation.property = 'bar'; diff --git a/src/expectation/strong-expectation.spec.ts b/src/expectation/strong-expectation.spec.ts index 2b03add0..50f98935 100644 --- a/src/expectation/strong-expectation.spec.ts +++ b/src/expectation/strong-expectation.spec.ts @@ -18,42 +18,65 @@ describe('StrongExpectation', () => { expect(expectation.matches([1])).toBeFalsy(); }); - it('should match optional args against undefined', () => { - const expectation = new StrongExpectation( - 'bar', - [It.deepEquals(undefined)], - { - value: 23, - } - ); + describe('non exact params', () => { + it('should match missing args against undefined', () => { + const expectation = new StrongExpectation( + 'bar', + [It.deepEquals(undefined)], + { + value: 23, + } + ); + + expect(expectation.matches([])).toBeTruthy(); + }); - expect(expectation.matches([])).toBeTruthy(); - }); + it('should match extra args', () => { + const expectation = new StrongExpectation('bar', [], { value: 23 }); - it('should match passed in optional args', () => { - const expectation = new StrongExpectation('bar', [], { value: 23 }); + expect(expectation.matches([42])).toBeTruthy(); + }); - expect(expectation.matches([42])).toBeTruthy(); - }); + it('should not match less args', () => { + const expectation = new StrongExpectation('bar', [It.deepEquals(23)], { + value: 23, + }); - it('should not match missing expected optional arg', () => { - const expectation = new StrongExpectation('bar', [It.deepEquals(23)], { - value: 23, + expect(expectation.matches([])).toBeFalsy(); }); - expect(expectation.matches([])).toBeFalsy(); + it('should not match expected undefined verses received defined arg', () => { + const expectation = new StrongExpectation( + 'bar', + [It.deepEquals(undefined)], + { + value: 23, + } + ); + + expect(expectation.matches([42])).toBeFalsy(); + }); }); - it('should not match defined expected undefined optional arg', () => { - const expectation = new StrongExpectation( - 'bar', - [It.deepEquals(undefined)], - { - value: 23, - } - ); + describe('exact params', () => { + it('should not match more args', () => { + const expectation = new StrongExpectation('bar', [], { value: 23 }, true); - expect(expectation.matches([42])).toBeFalsy(); + expect(expectation.matches([42])).toBeFalsy(); + }); + + it('should not match less args', () => { + const expectation = new StrongExpectation( + 'bar', + [It.deepEquals(23)], + { + value: 23, + }, + true + ); + + expect(expectation.matches([])).toBeFalsy(); + }); }); it('should print when, returns and invocation count', () => { diff --git a/src/expectation/strong-expectation.ts b/src/expectation/strong-expectation.ts index ef87e6c4..ec038500 100644 --- a/src/expectation/strong-expectation.ts +++ b/src/expectation/strong-expectation.ts @@ -24,7 +24,8 @@ export class StrongExpectation implements Expectation { constructor( public property: Property, public args: Matcher[] | undefined, - public returnValue: ReturnValue + public returnValue: ReturnValue, + private exactParams: boolean = false ) {} setInvocationCount(min: number, max = 1) { @@ -55,6 +56,12 @@ export class StrongExpectation implements Expectation { return false; } + if (this.exactParams) { + if (this.args.length !== received.length) { + return false; + } + } + return this.args.every((arg, i) => arg.matches(received[i])); } diff --git a/src/mock/defaults.ts b/src/mock/defaults.ts index 02423f1f..6258b000 100644 --- a/src/mock/defaults.ts +++ b/src/mock/defaults.ts @@ -6,6 +6,7 @@ export type StrongMockDefaults = Required; const defaults: StrongMockDefaults = { concreteMatcher: It.deepEquals, strictness: Strictness.STRICT, + exactParams: false, }; export let currentDefaults: StrongMockDefaults = defaults; diff --git a/src/mock/mock.ts b/src/mock/mock.ts index 4c233439..c02676ad 100644 --- a/src/mock/mock.ts +++ b/src/mock/mock.ts @@ -16,13 +16,15 @@ const strongExpectationFactory: ExpectationFactory = ( property, args, returnValue, - concreteMatcher + concreteMatcher, + exactParams ) => new StrongExpectation( property, // Wrap every non-matcher in the default matcher. args?.map((arg) => (isMatcher(arg) ? arg : concreteMatcher(arg))), - returnValue + returnValue, + exactParams ); export enum Mode { @@ -46,6 +48,8 @@ export const setMode = (mode: Mode) => { * @param options.strictness Controls what happens when a property is accessed, * or a call is made, and there are no expectations set for it. * @param options.concreteMatcher The matcher that will be used when one isn't specified explicitly. + * @param options.exactParams Controls whether the number of received arguments has to + * match the expectation. * * @example * const fn = mock<() => number>(); @@ -57,6 +61,7 @@ export const setMode = (mode: Mode) => { export const mock = ({ strictness, concreteMatcher, + exactParams, }: MockOptions = {}): Mock => { const pendingExpectation = new RepoSideEffectPendingExpectation( strongExpectationFactory @@ -65,6 +70,7 @@ export const mock = ({ const options: StrongMockDefaults = { strictness: strictness ?? currentDefaults.strictness, concreteMatcher: concreteMatcher ?? currentDefaults.concreteMatcher, + exactParams: exactParams ?? currentDefaults.exactParams, }; const repository = new FlexibleRepository(options.strictness); @@ -73,7 +79,8 @@ export const mock = ({ repository, pendingExpectation, () => currentMode, - options.concreteMatcher + options.concreteMatcher, + options.exactParams ); setMockState(stub, { diff --git a/src/mock/options.ts b/src/mock/options.ts index 2333b083..fd454d11 100644 --- a/src/mock/options.ts +++ b/src/mock/options.ts @@ -51,6 +51,24 @@ export interface MockOptions { */ strictness?: Strictness; + /** + * If `true`, the number of received arguments in a function/method call has to + * match the number of arguments set in the expectation. + * + * If `false`, extra parameters are considered optional and checked by the + * TypeScript compiler instead. + * + * You may want to set this to `true` if you're not using TypeScript, + * or if you want to be extra strict. + * + * @example + * const fn = mock<(value?: number) => number>({ exactParams: true }); + * when(() => fn()).thenReturn(42); + * + * fn(100) // throws with exactParams, returns 42 without + */ + exactParams?: boolean; + /** * The matcher that will be used when one isn't specified explicitly. * diff --git a/src/mock/stub.spec.ts b/src/mock/stub.spec.ts index 23ce37e2..866fa393 100644 --- a/src/mock/stub.spec.ts +++ b/src/mock/stub.spec.ts @@ -26,7 +26,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub(1, 2, 3); @@ -47,7 +48,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.call(null, 1, 2, 3); @@ -68,7 +70,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.apply(null, [1, 2, 3]); @@ -89,7 +92,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); Reflect.apply(stub, null, [1, 2, 3]); @@ -110,7 +114,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.bind(null, 1, 2)(3); @@ -131,7 +136,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.bar(1, 2, 3); @@ -152,7 +158,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.bar.call(null, 1, 2, 3); @@ -173,7 +180,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.bar.apply(null, [1, 2, 3]); @@ -194,7 +202,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); stub.bar.bind(null, 1, 2)(3); @@ -215,7 +224,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); expect(() => stub.foo.bar).toThrow(NestedWhen); @@ -231,7 +241,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); expect(() => ({ ...stub })).toThrow(); @@ -246,7 +257,8 @@ describe('createStub', () => { repo, pendingExpectation, expectMode, - It.deepEquals + It.deepEquals, + false ); expect(() => ({ ...stub.bar })).toThrow(); @@ -267,7 +279,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); expect(fn(1)).toEqual(42); @@ -280,7 +293,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); expect(foo.bar(1)).toEqual(42); @@ -293,7 +307,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); expect(foo.bar).toEqual(42); @@ -306,7 +321,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); expect(() => foo.bar).toThrow('foo'); @@ -319,7 +335,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); await expect(foo.bar).resolves.toEqual('foo'); @@ -336,7 +353,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); await expect(foo.bar).rejects.toThrow('foo'); @@ -355,7 +373,8 @@ describe('createStub', () => { SM.instance(repo), unusedPendingExpectation, callMode, - It.deepEquals + It.deepEquals, + false ); expect({ ...foo }).toEqual({ foo: 1, bar: 2, [baz]: 3 }); diff --git a/src/mock/stub.ts b/src/mock/stub.ts index 6bf7007e..f2d034b6 100644 --- a/src/mock/stub.ts +++ b/src/mock/stub.ts @@ -38,7 +38,8 @@ export const createStub = ( repo: ExpectationRepository, pendingExpectation: PendingExpectation, getCurrentMode: () => Mode, - concreteMatcher: ConcreteMatcher + concreteMatcher: ConcreteMatcher, + exactParams: boolean ): Mock => { const stub = createProxy({ property: (property) => { @@ -48,7 +49,7 @@ export const createStub = ( setActiveMock(stub); - pendingExpectation.start(repo, concreteMatcher); + pendingExpectation.start(repo, concreteMatcher, exactParams); // eslint-disable-next-line no-param-reassign pendingExpectation.property = property; @@ -73,7 +74,7 @@ export const createStub = ( setActiveMock(stub); - pendingExpectation.start(repo, concreteMatcher); + pendingExpectation.start(repo, concreteMatcher, exactParams); // eslint-disable-next-line no-param-reassign pendingExpectation.property = ApplyProp; // eslint-disable-next-line no-param-reassign diff --git a/src/when/pending-expectation.ts b/src/when/pending-expectation.ts index bac74b3d..75c0f112 100644 --- a/src/when/pending-expectation.ts +++ b/src/when/pending-expectation.ts @@ -9,12 +9,17 @@ export type ExpectationFactory = ( property: Property, args: any[] | undefined, returnValue: ReturnValue, - concreteMatcher: ConcreteMatcher + concreteMatcher: ConcreteMatcher, + exactParams: boolean ) => Expectation; export interface PendingExpectation { // TODO: get rid of repo - start(repo: ExpectationRepository, concreteMatcher: ConcreteMatcher): void; + start( + repo: ExpectationRepository, + concreteMatcher: ConcreteMatcher, + exactParams: boolean + ): void; finish(returnValue: ReturnValue): Expectation; @@ -39,9 +44,15 @@ export class RepoSideEffectPendingExpectation implements PendingExpectation { private _property: Property = ''; + private _exactParams: boolean | undefined; + constructor(private createExpectation: ExpectationFactory) {} - start(repo: ExpectationRepository, concreteMatcher: ConcreteMatcher) { + start( + repo: ExpectationRepository, + concreteMatcher: ConcreteMatcher, + exactParams: boolean + ) { if (this._repo) { throw new UnfinishedExpectation(this); } @@ -50,6 +61,7 @@ export class RepoSideEffectPendingExpectation implements PendingExpectation { this._repo = repo; this._concreteMatcher = concreteMatcher; + this._exactParams = exactParams; } set property(value: Property) { @@ -61,7 +73,11 @@ export class RepoSideEffectPendingExpectation implements PendingExpectation { } finish(returnValue: ReturnValue): Expectation { - if (!this._repo || !this._concreteMatcher) { + if ( + !this._repo || + !this._concreteMatcher || + this._exactParams === undefined + ) { throw new MissingWhen(); } @@ -69,7 +85,8 @@ export class RepoSideEffectPendingExpectation implements PendingExpectation { this._property, this._args, returnValue, - this._concreteMatcher + this._concreteMatcher, + this._exactParams ); this._repo.add(expectation); diff --git a/tests/e2e.spec.ts b/tests/e2e.spec.ts index e5115f0c..09ab61b7 100644 --- a/tests/e2e.spec.ts +++ b/tests/e2e.spec.ts @@ -143,7 +143,15 @@ describe('e2e', () => { expect(mock1(mock2)).toBeTruthy(); }); - describe('ignoring arguments', () => { + it('', () => { + const fn = mock<(x?: number) => number>({ exactParams: true }); + + when(() => fn()).thenReturn(42); + + expect(() => fn(100)).toThrow(); + }); + + describe('matching arguments', () => { it('should support matching anything', () => { const fn = mock();