Skip to content

Commit

Permalink
feat: thenReject now lazily creates promise rejection
Browse files Browse the repository at this point in the history
Previously, `thenReject` would instantly create a `Promise.reject()`
that could cause problems with code that did not immediately call the
instance and capture the promise.

Now, the expectation sets an internal value that will be turned into a
promise rejection only when the instance is called. This should avoid
the unhandled rejection warnings that you can get when either 1) the
expectation is not met or 2) there is code that flushes promises before
they're actually caught, such as `act` from `@testing-library/react`.

Moreover, this should help avoid pausing a debugger on `thenReject` when
"Pause on any exception" is checked.

Fixes #238.
  • Loading branch information
NiGhTTraX committed Jun 24, 2021
1 parent a1ad324 commit 01c9995
Show file tree
Hide file tree
Showing 14 changed files with 165 additions and 106 deletions.
2 changes: 1 addition & 1 deletion src/expectation/expectation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Property } from '../proxy';
export type ReturnValue = {
value: any;
isPromise?: boolean;
promiseValue?: any;
promiseValue?: any; // TODO: remove, since value is equal to this
isError?: boolean;
};

Expand Down
51 changes: 29 additions & 22 deletions src/expectation/repository/base-repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { returnOrThrow } from '../../instance/instance';
import { ApplyProp, Expectation } from '../expectation';
import { ApplyProp, Expectation, ReturnValue } from '../expectation';
import { CallMap, ExpectationRepository } from './expectation-repository';
import { Property } from '../../proxy';

Expand Down Expand Up @@ -35,7 +35,7 @@ export abstract class BaseRepository implements ExpectationRepository {
this.unexpectedCallStats.clear();
}

get(property: Property): any {
get(property: Property): ReturnValue {
const expectations = this.expectations.get(property);

if (expectations && expectations.length) {
Expand All @@ -50,45 +50,50 @@ export abstract class BaseRepository implements ExpectationRepository {
if (propertyExpectation) {
this.countAndConsume(propertyExpectation);

return propertyExpectation.expectation.returnValue.value;
return propertyExpectation.expectation.returnValue;
}

return (...args: any[]) => {
const callExpectation = expectations.find((e) =>
e.expectation.matches(args)
);
return {
value: (...args: any[]) => {
const callExpectation = expectations.find((e) =>
e.expectation.matches(args)
);

if (callExpectation) {
this.recordExpected(property, args);
this.countAndConsume(callExpectation);
if (callExpectation) {
this.recordExpected(property, args);
this.countAndConsume(callExpectation);

return returnOrThrow(callExpectation.expectation.returnValue.value);
}
// TODO: this is duplicated in instance
return returnOrThrow(callExpectation.expectation.returnValue);
}

this.recordUnexpected(property, args);
return this.getValueForUnexpectedCall(property, args);
this.recordUnexpected(property, args);
return this.getValueForUnexpectedCall(property, args);
},
};
}

switch (property) {
case 'toString':
return () => 'mock';
return { value: () => 'mock' };
case '@@toStringTag':
case Symbol.toStringTag:
case 'name':
return 'mock';
return { value: 'mock' };

// pretty-format
case '$$typeof':
case 'constructor':
case '@@__IMMUTABLE_ITERABLE__@@':
case '@@__IMMUTABLE_RECORD__@@':
return null;
return { value: null };

case ApplyProp:
return (...args: any[]) => {
this.recordUnexpected(property, args);
return this.getValueForUnexpectedCall(property, args);
return {
value: (...args: any[]) => {
this.recordUnexpected(property, args);
return this.getValueForUnexpectedCall(property, args);
},
};
default:
this.recordUnexpected(property, undefined);
Expand Down Expand Up @@ -124,13 +129,15 @@ export abstract class BaseRepository implements ExpectationRepository {
protected abstract getValueForUnexpectedCall(
property: Property,
args: any[]
): any;
): ReturnValue;

/**
* We got a property access that doesn't match any expectation,
* what should we return?
*/
protected abstract getValueForUnexpectedAccess(property: Property): any;
protected abstract getValueForUnexpectedAccess(
property: Property
): ReturnValue;

protected abstract consumeExpectation(
expectation: CountableExpectation
Expand Down
5 changes: 3 additions & 2 deletions src/expectation/repository/expectation-repository.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export class OneIncomingExpectationRepository implements ExpectationRepository {
this.expectation = expectation;
}

get(): ReturnValue | undefined {
return this.expectation?.returnValue;
get(): ReturnValue {
if (!this.expectation) throw new Error();
return this.expectation.returnValue;
}

getAllProperties(): Property[] {
Expand Down
10 changes: 5 additions & 5 deletions src/expectation/repository/expectation-repository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Expectation } from '../expectation';
import { Expectation, ReturnValue } from '../expectation';
import { Property } from '../../proxy';

export type Call = {
Expand Down Expand Up @@ -47,17 +47,17 @@ export interface ExpectationRepository {
*
* @example
* add(new Expectation('getData', [1, 2], 23);
* get('getData')(1, 2) === 23
* get('getData').value(1, 2) === 23
*
* @example
* add(new Expectation('hasData', undefined, true);
* get('hasData') === true
* get('hasData').value === true
*
* @example
* add(new Expectation('getData', undefined, () => 42);
* get('getData')(1, 2, '3', false, NaN) === 42
* get('getData').value(1, 2, '3', false, NaN) === 42
*/
get(property: Property): any;
get(property: Property): ReturnValue;

/**
* Get all the properties that have expectations.
Expand Down
57 changes: 32 additions & 25 deletions src/expectation/repository/repo.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const repoContractTests: ExpectationRepositoryContract = {
test: (repo) => () => {
repo.add(new MatchingPropertyExpectation('foo', { value: 23 }));

expect(repo.get('foo')).toEqual(23);
expect(repo.get('foo').value).toEqual(23);
},
},
{
Expand All @@ -33,8 +33,8 @@ export const repoContractTests: ExpectationRepositoryContract = {
repo.add(new MatchingPropertyExpectation('foo', { value: 23 }));
repo.add(new MatchingPropertyExpectation('foo', { value: 42 }));

expect(repo.get('foo')).toEqual(23);
expect(repo.get('foo')).toEqual(42);
expect(repo.get('foo').value).toEqual(23);
expect(repo.get('foo').value).toEqual(42);
},
},
{
Expand All @@ -46,9 +46,9 @@ export const repoContractTests: ExpectationRepositoryContract = {
expectation.setInvocationCount(2, 3);
repo.add(expectation);

expect(repo.get('foo')).toEqual(23);
expect(repo.get('foo')).toEqual(23);
expect(repo.get('foo')).toEqual(23);
expect(repo.get('foo').value).toEqual(23);
expect(repo.get('foo').value).toEqual(23);
expect(repo.get('foo').value).toEqual(23);
},
},
],
Expand All @@ -59,7 +59,7 @@ export const repoContractTests: ExpectationRepositoryContract = {
test: (repo) => () => {
repo.add(new MatchingCallExpectation('foo', { value: 23 }));

expect(repo.get('foo')()).toEqual(23);
expect(repo.get('foo').value()).toEqual(23);
},
},
{
Expand All @@ -68,18 +68,22 @@ export const repoContractTests: ExpectationRepositoryContract = {
repo.add(new MatchingCallExpectation('foo', { value: 23 }));
repo.add(new MatchingCallExpectation('foo', { value: 42 }));

expect(repo.get('foo')()).toEqual(23);
expect(repo.get('foo')()).toEqual(42);
expect(repo.get('foo').value()).toEqual(23);
expect(repo.get('foo').value()).toEqual(42);
},
},
{
name: 'should match property expectations before function expectations',
test: (repo) => () => {
repo.add(new MatchingCallExpectation('foo', { value: 1 }));
repo.add(new MatchingPropertyExpectation('foo', { value: () => 2 }));
repo.add(
new MatchingPropertyExpectation('foo', {
value: () => ({ value: 2 }),
})
);

expect(repo.get('foo')()).toEqual(2);
expect(repo.get('foo')()).toEqual(1);
expect(repo.get('foo').value()).toEqual({ value: 2 });
expect(repo.get('foo').value()).toEqual(1);
},
},
{
Expand All @@ -89,20 +93,21 @@ export const repoContractTests: ExpectationRepositoryContract = {
expectation.setInvocationCount(2, 3);
repo.add(expectation);

expect(repo.get('foo')()).toEqual(23);
expect(repo.get('foo')()).toEqual(23);
expect(repo.get('foo')()).toEqual(23);
expect(repo.get('foo').value()).toEqual(23);
expect(repo.get('foo').value()).toEqual(23);
expect(repo.get('foo').value()).toEqual(23);
},
},
{
name: 'should throw if the value is an error',
test: (repo) => () => {
const expectation = new MatchingCallExpectation('foo', {
value: new Error(),
isError: true,
});
repo.add(expectation);

expect(() => repo.get('foo')()).toThrow();
expect(() => repo.get('foo').value()).toThrow();
},
},
],
Expand Down Expand Up @@ -201,7 +206,7 @@ export const repoContractTests: ExpectationRepositoryContract = {
const expectation = new MatchingCallExpectation('foo', { value: 23 });
repo.add(expectation);

repo.get('foo')(1, 2);
repo.get('foo').value(1, 2);

const callStats: CallStats = {
expected: new Map([
Expand Down Expand Up @@ -233,7 +238,7 @@ export const repoContractTests: ExpectationRepositoryContract = {
repo.add(new NotMatchingExpectation('foo', { value: 23 }));

try {
repo.get('foo')(1, 2, 3);
repo.get('foo').value(1, 2, 3);
} catch (e) {}

const callStats: CallStats = {
Expand Down Expand Up @@ -280,9 +285,9 @@ export const repoContractTests: ExpectationRepositoryContract = {
{
name: 'should return values for toString and friends',
test: (repo) => () => {
expect(repo.get('toString')()).toBeTruthy();
expect(repo.get('@@toStringTag')).toBeTruthy();
expect(repo.get(Symbol.toStringTag)).toBeTruthy();
expect(repo.get('toString').value()).toBeTruthy();
expect(repo.get('@@toStringTag').value).toBeTruthy();
expect(repo.get(Symbol.toStringTag).value).toBeTruthy();
},
},
{
Expand All @@ -309,10 +314,12 @@ export const repoContractTests: ExpectationRepositoryContract = {
})
);

expect(repo.get('toString')()).toEqual('not a mock');
expect(repo.get('toString')()).toEqual('I said not a mock');
expect(repo.get('@@toStringTag')).toEqual('totally not a mock');
expect(repo.get(Symbol.toStringTag)).toEqual('absolutely not a mock');
expect(repo.get('toString').value()).toEqual('not a mock');
expect(repo.get('toString').value()).toEqual('I said not a mock');
expect(repo.get('@@toStringTag').value).toEqual('totally not a mock');
expect(repo.get(Symbol.toStringTag).value).toEqual(
'absolutely not a mock'
);
},
},
],
Expand Down
6 changes: 3 additions & 3 deletions src/expectation/repository/strong-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ describe('StrongRepository', () => {
const repo = new StrongRepository();
repo.add(new NotMatchingExpectation('foo', { value: 23 }));

expect(() => repo.get('foo')(3, 4)).toThrow(UnexpectedCall);
expect(() => repo.get('foo').value(3, 4)).toThrow(UnexpectedCall);
});

it('should throw if no apply expectations', () => {
const repo = new StrongRepository();

expect(() => repo.get(ApplyProp)(1, 2, 3)).toThrow(UnexpectedCall);
expect(() => repo.get(ApplyProp).value(1, 2, 3)).toThrow(UnexpectedCall);
});

it('should throw after a property expectation is fulfilled', () => {
Expand All @@ -49,7 +49,7 @@ describe('StrongRepository', () => {
it('should throw after a function expectation is fulfilled', () => {
const repo = new StrongRepository();
repo.add(new MatchingCallExpectation('foo', { value: 23 }));
repo.get('foo')(1, 2);
repo.get('foo').value(1, 2);

expect(() => repo.get('foo')).toThrow(UnexpectedAccess);
});
Expand Down
4 changes: 2 additions & 2 deletions src/expectation/repository/strong-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ export class StrongRepository extends BaseRepository {
}
}

protected getValueForUnexpectedCall(property: Property, args: any[]) {
protected getValueForUnexpectedCall(property: Property, args: any[]): never {
throw new UnexpectedCall(property, args, this.getUnmet());
}

protected getValueForUnexpectedAccess(property: Property) {
protected getValueForUnexpectedAccess(property: Property): never {
throw new UnexpectedAccess(property, this.getUnmet());
}
}
Loading

0 comments on commit 01c9995

Please sign in to comment.