Skip to content

Commit d389963

Browse files
authored
fix: makeApiContextWrapper and createMockProxy (#1312)
createMockProxy - added `ref` to auto proxy exclusions - now prints object properties instead of `undefined` in jest matchers. - Proxied mock methods now include a mockName() call to give clearer output fixes #1311
1 parent 8c45b26 commit d389963

4 files changed

Lines changed: 189 additions & 64 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import createMockProxy, { MockProxySymbol } from './MockProxy';
2+
3+
describe('createMockProxy', () => {
4+
it('should proxy property access as jest.fn() unless explicitly set', () => {
5+
const mock = createMockProxy<Record<string, unknown>>({
6+
name: 'mock.name',
7+
});
8+
9+
expect(mock.name).toEqual('mock.name');
10+
expect(mock.propA).toBeInstanceOf(jest.fn().constructor);
11+
expect(mock.propB).toBeInstanceOf(jest.fn().constructor);
12+
});
13+
14+
it('should not interfere with `await` by not proxying `then` property', async () => {
15+
const mock = createMockProxy<Record<string, unknown>>({});
16+
expect(mock.then).toBeUndefined();
17+
18+
const result = await mock;
19+
20+
expect(result).toBe(mock);
21+
});
22+
23+
it('should only show `in` for explicit properties', () => {
24+
const mock = createMockProxy<Record<string, unknown>>({
25+
name: 'mock.name',
26+
age: 42,
27+
});
28+
29+
expect('name' in mock).toBeTruthy();
30+
expect('age' in mock).toBeTruthy();
31+
expect('blah' in mock).toBeFalsy();
32+
});
33+
34+
it.each([
35+
Symbol.iterator,
36+
'then',
37+
'asymmetricMatch',
38+
'hasAttribute',
39+
'nodeType',
40+
'ref',
41+
'tagName',
42+
'toJSON',
43+
])('should return undefined for default props', prop => {
44+
const mock = createMockProxy();
45+
46+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
47+
expect((mock as any)[prop]).toBeUndefined();
48+
});
49+
50+
it('should return custom Symbol.toStringTag', () => {
51+
const mock = createMockProxy();
52+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
53+
expect((mock as any)[Symbol.toStringTag]).toEqual('Mock Proxy');
54+
});
55+
56+
it('should return internal storage by name', () => {
57+
const overrides = {
58+
name: 'mock.name',
59+
age: 42,
60+
};
61+
62+
const mock = createMockProxy<{
63+
name: string;
64+
age: number;
65+
testMethod: () => void;
66+
}>(overrides);
67+
68+
mock.testMethod();
69+
70+
expect(mock[MockProxySymbol.defaultProps]).toEqual({
71+
then: undefined,
72+
asymmetricMatch: undefined,
73+
hasAttribute: undefined,
74+
nodeType: undefined,
75+
ref: undefined,
76+
tagName: undefined,
77+
toJSON: undefined,
78+
[Symbol.iterator]: undefined,
79+
});
80+
81+
expect(mock[MockProxySymbol.overrides]).toEqual(overrides);
82+
83+
expect(mock[MockProxySymbol.proxies]).toEqual({
84+
testMethod: expect.any(Function),
85+
});
86+
expect(mock.testMethod).toBeInstanceOf(jest.fn().constructor);
87+
});
88+
});

packages/utils/src/MockProxy.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
const defaultPropsSymbol: unique symbol = Symbol('mockProxyDefaultProps');
2+
const overridesSymbol: unique symbol = Symbol('mockProxyOverrides');
3+
const proxiesSymbol: unique symbol = Symbol('mockProxyProxies');
4+
5+
export const MockProxySymbol = {
6+
defaultProps: defaultPropsSymbol,
7+
overrides: overridesSymbol,
8+
proxies: proxiesSymbol,
9+
} as const;
10+
11+
// Set default values on certain properties so they don't get automatically
12+
// proxied as jest.fn() instances.
13+
const mockProxyDefaultProps = {
14+
// `Symbol.iterator` - returning a jest.fn() throws a TypeError
15+
// `then` - avoid issues with `await` treating object as "thenable"
16+
[Symbol.iterator]: undefined,
17+
then: undefined,
18+
// Jest makes calls to `asymmetricMatch`, `hasAttribute`, `nodeType`
19+
// `tagName`, and `toJSON`. react-test-renderer checks `ref`
20+
asymmetricMatch: undefined,
21+
ref: undefined,
22+
hasAttribute: undefined,
23+
nodeType: undefined,
24+
tagName: undefined,
25+
toJSON: undefined,
26+
};
27+
28+
/**
29+
* The proxy target contains state + configuration for the proxy
30+
*/
31+
export interface MockProxyTarget<T> {
32+
[MockProxySymbol.defaultProps]: typeof mockProxyDefaultProps;
33+
[MockProxySymbol.overrides]: Partial<T>;
34+
[MockProxySymbol.proxies]: Record<keyof T, jest.Mock>;
35+
}
36+
37+
/**
38+
* Creates a mock object for a type `T` using a Proxy object. Each prop can
39+
* optionally be set via the constructor. Any prop that is not set will be set
40+
* to a jest.fn() instance on first access with the exeption of "then" which
41+
* will not be automatically proxied.
42+
* @param overrides Optional props to explicitly set on the Proxy.
43+
* @returns
44+
*/
45+
export default function createMockProxy<T>(
46+
overrides: Partial<T> = {}
47+
): T & MockProxyTarget<T> {
48+
const targetDef: MockProxyTarget<T> = {
49+
[MockProxySymbol.defaultProps]: mockProxyDefaultProps,
50+
[MockProxySymbol.overrides]: overrides,
51+
[MockProxySymbol.proxies]: {} as Record<keyof T, jest.Mock>,
52+
};
53+
54+
return new Proxy(targetDef, {
55+
get(target, name) {
56+
if (name === Symbol.toStringTag) {
57+
return 'Mock Proxy';
58+
}
59+
60+
// Reserved attributes for the proxy
61+
if (
62+
MockProxySymbol.defaultProps === name ||
63+
MockProxySymbol.overrides === name ||
64+
MockProxySymbol.proxies === name
65+
) {
66+
return target[name as keyof typeof target];
67+
}
68+
69+
// Properties that have been explicitly overriden
70+
if (name in target[MockProxySymbol.overrides]) {
71+
return target[MockProxySymbol.overrides][name as keyof Partial<T>];
72+
}
73+
74+
// Properties that have defaults set
75+
if (name in target[MockProxySymbol.defaultProps]) {
76+
return target[MockProxySymbol.defaultProps][
77+
name as keyof typeof mockProxyDefaultProps
78+
];
79+
}
80+
81+
// Any other property access will create and cache a jest.fn() instance
82+
if (target[MockProxySymbol.proxies][name as keyof T] == null) {
83+
// eslint-disable-next-line no-param-reassign
84+
target[MockProxySymbol.proxies][name as keyof T] = jest
85+
.fn()
86+
.mockName(String(name));
87+
}
88+
89+
return target[MockProxySymbol.proxies][name as keyof T];
90+
},
91+
// Only consider explicitly defined props as "in" the proxy
92+
has(target, name) {
93+
return name in target[MockProxySymbol.overrides];
94+
},
95+
}) as T & typeof targetDef;
96+
}

packages/utils/src/TestUtils.test.tsx

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44

55
import TestUtils from './TestUtils';
6+
import createMockProxy from './MockProxy';
67

78
beforeEach(() => {
89
jest.clearAllMocks();
@@ -150,35 +151,7 @@ describe('click', () => {
150151
});
151152

152153
describe('createMockProxy', () => {
153-
it('should proxy property access as jest.fn() unless explicitly set', () => {
154-
const mock = TestUtils.createMockProxy<Record<string, unknown>>({
155-
name: 'mock.name',
156-
});
157-
158-
expect(mock.name).toEqual('mock.name');
159-
expect(mock.propA).toBeInstanceOf(jest.fn().constructor);
160-
expect(mock.propB).toBeInstanceOf(jest.fn().constructor);
161-
});
162-
163-
it('should not interfere with `await` by not proxying `then` property', async () => {
164-
const mock = TestUtils.createMockProxy<Record<string, unknown>>({});
165-
expect(mock.then).toBeUndefined();
166-
167-
const result = await mock;
168-
169-
expect(result).toBe(mock);
170-
});
171-
172-
it('should only show `in` for explicit properties', () => {
173-
const mock = TestUtils.createMockProxy<Record<string, unknown>>({
174-
name: 'mock.name',
175-
age: 42,
176-
});
177-
178-
expect('name' in mock).toBeTruthy();
179-
expect('age' in mock).toBeTruthy();
180-
expect('blah' in mock).toBeFalsy();
181-
});
154+
expect(TestUtils.createMockProxy).toBe(createMockProxy);
182155
});
183156

184157
describe('extractCallArgs', () => {

packages/utils/src/TestUtils.ts

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type userEvent from '@testing-library/user-event';
2+
import createMockProxy from './MockProxy';
23

34
interface MockContext {
45
arc: jest.Mock<void>;
@@ -179,42 +180,9 @@ class TestUtils {
179180
* optionally be set via the constructor. Any prop that is not set will be set
180181
* to a jest.fn() instance on first access with the exeption of "then" which
181182
* will not be automatically proxied.
182-
* @param props Optional props to explicitly set on the Proxy.
183-
* @returns
183+
* @param overrides Optional props to explicitly set on the Proxy.
184184
*/
185-
static createMockProxy<T>(props: Partial<T> = {}): T {
186-
return new Proxy(
187-
{
188-
props: {
189-
// Disable auto-proxying of certain properties that cause trouble.
190-
// - Symbol.iterator - returning a jest.fn() throws a TypeError
191-
// - then - avoid issues with `await` treating object as "thenable"
192-
[Symbol.iterator]: undefined,
193-
then: undefined,
194-
...props,
195-
},
196-
proxies: {} as Record<keyof T, jest.Mock>,
197-
},
198-
{
199-
get(target, name) {
200-
if (name in target.props) {
201-
return target.props[name as keyof T];
202-
}
203-
204-
if (target.proxies[name as keyof T] == null) {
205-
// eslint-disable-next-line no-param-reassign
206-
target.proxies[name as keyof T] = jest.fn();
207-
}
208-
209-
return target.proxies[name as keyof T];
210-
},
211-
// Only consider explicitly defined props as "in" the proxy
212-
has(target, name) {
213-
return name in target.props;
214-
},
215-
}
216-
) as T;
217-
}
185+
static createMockProxy = createMockProxy;
218186

219187
/**
220188
* Attempt to extract the args for the nth call to a given function. This will

0 commit comments

Comments
 (0)