Skip to content

Commit

Permalink
feat!: Throw on unexpected calls when verifying mock
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `verify` used to rely on unexpected calls throwing
when they were made. However, the code under test could `try..catch`
that error and never bubble it up to the test. Unless the test
explicitly checked that the SUT was not in an error state, `verify`
would have not been enough to make sure that everything was correct.
Now it throws if any unexpected calls happened.
  • Loading branch information
NiGhTTraX committed May 3, 2020
1 parent 99181a5 commit f68e2f2
Show file tree
Hide file tree
Showing 5 changed files with 171 additions and 7 deletions.
23 changes: 23 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EXPECTED_COLOR } from 'jest-matcher-utils';
import { Expectation, Expectation2 } from './expectation';
import { CallMap } from './expectation-repository';
import { PendingExpectation } from './pending-expectation';
import { printCall, printProperty, printRemainingExpectations } from './print';

Expand Down Expand Up @@ -71,3 +72,25 @@ export class UnmetExpectations extends Error {
- ${expectations.map((e) => e.toJSON()).join('\n - ')}`);
}
}

export class UnexpectedCalls extends Error {
constructor(unexpectedCalls: CallMap, expectations: Expectation2[]) {
const printedCalls = Array.from(unexpectedCalls.entries())
.map(([property, calls]) =>
calls
.map((call) =>
call.arguments
? EXPECTED_COLOR(`mock${printCall(property, call.arguments)}`)
: EXPECTED_COLOR(`mock${printProperty(property)}`)
)
.join('\n - ')
)
.join('\n - ');

super(`The following calls were unexpected:
- ${printedCalls}
${printRemainingExpectations(expectations)}`);
}
}
19 changes: 18 additions & 1 deletion src/map.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { NotAMock } from './errors';
import { ExpectationRepository } from './expectation-repository';
import {
ExpectationRepository,
ExpectationRepository2,
} from './expectation-repository';
import { Mock } from './mock';
import { PendingExpectation } from './pending-expectation';

Expand Down Expand Up @@ -38,13 +41,19 @@ type MockState = {
pendingExpectation: PendingExpectation;
};

type MockState2 = {
repository: ExpectationRepository2;
pendingExpectation: PendingExpectation;
};

/**
* Store a global map of all mocks created and their state.
*
* This is needed because we can't reliably pass the state between `when`,
* `thenReturn` and `instance`.
*/
export const mockMap = new Map<Mock<any>, MockState>();
export const mockMap2 = new Map<Mock<any>, MockState2>();

export const getMockState = (mock: Mock<any>): MockState => {
if (mockMap.has(mock)) {
Expand All @@ -53,3 +62,11 @@ export const getMockState = (mock: Mock<any>): MockState => {

throw new NotAMock();
};

export const getMockState2 = (mock: Mock<any>): MockState2 => {
if (mockMap2.has(mock)) {
return mockMap2.get(mock)!;
}

throw new NotAMock();
};
26 changes: 24 additions & 2 deletions src/verify.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UnmetExpectations } from './errors';
import { getMockState } from './map';
import { UnexpectedCalls, UnmetExpectations } from './errors';
import { ExpectationRepository2 } from './expectation-repository';
import { getMockState, getMockState2 } from './map';
import { Mock } from './mock';

/**
Expand All @@ -22,3 +23,24 @@ export const verify = <T>(mock: Mock<T>): void => {
throw new UnmetExpectations(unmetExpectations);
}
};

export const verify3 = (repository: ExpectationRepository2) => {
const unmetExpectations = repository.getUnmet();

if (unmetExpectations.length) {
throw new UnmetExpectations(unmetExpectations);
}

if (repository.getCallStats().unexpected.size) {
throw new UnexpectedCalls(
repository.getCallStats().unexpected,
repository.getUnmet()
);
}
};

export const verify2 = <T>(mock: Mock<T>): void => {
const { repository } = getMockState2(mock);

verify3(repository);
};
36 changes: 36 additions & 0 deletions tests/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { describe, it } from 'tdd-buffet/suite/node';
import {
UnexpectedAccess,
UnexpectedCall,
UnexpectedCalls,
UnfinishedExpectation,
UnmetExpectations,
} from '../src/errors';
import { CallMap } from '../src/expectation-repository';
import { RepoSideEffectPendingExpectation } from '../src/pending-expectation';
import { expectAnsilessContain, expectAnsilessEqual } from './ansiless';
import { EmptyRepository } from './expectation-repository';
import {
NeverMatchingExpectation,
NotMatchingExpectation,
spyExpectationFactory,
SpyPendingExpectation,
} from './expectations';
Expand Down Expand Up @@ -123,4 +126,37 @@ foobar`
);
});
});

describe('UnexpectedCalls', () => {
it('should print the unexpected calls and remaining expectations', () => {
const e1 = new NotMatchingExpectation(':irrelevant:', undefined);
const e2 = new NotMatchingExpectation(':irrelevant:', undefined);
e1.toJSON = () => 'e1';
e2.toJSON = () => 'e2';

const error = new UnexpectedCalls(
new Map([
['foo', [{ arguments: [1, 2, 3] }, { arguments: [4, 5, 6] }]],
['bar', [{ arguments: undefined }]],
]) as CallMap,
[e1, e2]
);

expectAnsilessContain(
error.message,
`The following calls were unexpected:
- mock.foo(1, 2, 3)
- mock.foo(4, 5, 6)
- mock.bar`
);

expectAnsilessContain(
error.message,
`Remaining unmet expectations:
- e1
- e2`
);
});
});
});
74 changes: 70 additions & 4 deletions tests/verify.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
/* eslint-disable class-methods-use-this */
import { expect } from 'tdd-buffet/expect/jest';
import { describe, it } from 'tdd-buffet/suite/node';
import { mock } from '../src';
import { UnmetExpectations } from '../src/errors';
import { verify } from '../src/verify';
import { UnexpectedCalls, UnmetExpectations } from '../src/errors';
import { Expectation2 } from '../src/expectation';
import { CallMap, ExpectationRepository2 } from '../src/expectation-repository';
import { verify, verify3 } from '../src/verify';
import {
EmptyRepository,
OneExistingExpectationRepository,
} from './expectation-repository';
import { OneUseAlwaysMatchingExpectation } from './expectations';
import {
NotMatchingExpectation,
OneUseAlwaysMatchingExpectation,
} from './expectations';

describe('verifyAll', () => {
describe('verify', () => {
it('should throw if remaining expectations', () => {
const repo = new OneExistingExpectationRepository(
new OneUseAlwaysMatchingExpectation()
Expand All @@ -26,3 +32,63 @@ describe('verifyAll', () => {
expect(() => verify(fn)).not.toThrow();
});
});

describe('verify2', () => {
class MockRepo implements ExpectationRepository2 {
private readonly unmet: Expectation2[] = [];

private readonly unexpected: CallMap = new Map();

private readonly expected: CallMap = new Map();

constructor({
expected = new Map(),
unexpected = new Map(),
unmet = [],
}: {
expected?: CallMap;
unexpected?: CallMap;
unmet?: Expectation2[];
} = {}) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.expected = expected;
// noinspection JSPotentiallyInvalidUsageOfThis
this.unexpected = unexpected;
// noinspection JSPotentiallyInvalidUsageOfThis
this.unmet = unmet;
}

add = () => {};

clear = () => {};

get = () => {};

getCallStats = () => ({
expected: this.expected,
unexpected: this.unexpected,
});

getUnmet = () => this.unmet;
}

it('should throw if remaining expectations', () => {
expect(() =>
verify3(
new MockRepo({
unmet: [new NotMatchingExpectation(':irrelevant:', undefined)],
})
)
).toThrow(UnmetExpectations);
});

it('should throw if unexpected calls', () => {
expect(() =>
verify3(
new MockRepo({
unexpected: new Map([['bar', [{ arguments: [] }]]]) as CallMap,
})
)
).toThrow(UnexpectedCalls);
});
});

0 comments on commit f68e2f2

Please sign in to comment.