Skip to content

Commit

Permalink
feat: Add exactParams option
Browse files Browse the repository at this point in the history
By default, function/method expectations allow receiving more arguments
than expected. This is useful if the function/method has optional
arguments defined, and you want to ignore them in the expectation. By
setting the new parameter `exactParams: true`, the number of received
arguments has to match the one in the expectation.
  • Loading branch information
NiGhTTraX committed Aug 20, 2022
1 parent ec488c9 commit 31acbbe
Show file tree
Hide file tree
Showing 11 changed files with 195 additions and 61 deletions.
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';

Expand Down
77 changes: 50 additions & 27 deletions src/expectation/strong-expectation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 8 additions & 1 deletion src/expectation/strong-expectation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]));
}

Expand Down
1 change: 1 addition & 0 deletions src/mock/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type StrongMockDefaults = Required<MockOptions>;
const defaults: StrongMockDefaults = {
concreteMatcher: It.deepEquals,
strictness: Strictness.STRICT,
exactParams: false,
};

export let currentDefaults: StrongMockDefaults = defaults;
Expand Down
13 changes: 10 additions & 3 deletions src/mock/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>();
Expand All @@ -57,6 +61,7 @@ export const setMode = (mode: Mode) => {
export const mock = <T>({
strictness,
concreteMatcher,
exactParams,
}: MockOptions = {}): Mock<T> => {
const pendingExpectation = new RepoSideEffectPendingExpectation(
strongExpectationFactory
Expand All @@ -65,6 +70,7 @@ export const mock = <T>({
const options: StrongMockDefaults = {
strictness: strictness ?? currentDefaults.strictness,
concreteMatcher: concreteMatcher ?? currentDefaults.concreteMatcher,
exactParams: exactParams ?? currentDefaults.exactParams,
};

const repository = new FlexibleRepository(options.strictness);
Expand All @@ -73,7 +79,8 @@ export const mock = <T>({
repository,
pendingExpectation,
() => currentMode,
options.concreteMatcher
options.concreteMatcher,
options.exactParams
);

setMockState(stub, {
Expand Down
18 changes: 18 additions & 0 deletions src/mock/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Loading

0 comments on commit 31acbbe

Please sign in to comment.