Skip to content

Commit 0a2f054

Browse files
authored
feat: Test Utils - Generate exhaustive boolean combinations and MockProxy spread (#1811)
New test util features - Generate exhaustive boolean combinations - Fixed some type errors in `usePickerWithSelectedValues.test.ts` (also updated it to use the new boolean test util) - Added spread support to MockProxy to avoid empty objects when making copies - Fixed a console error that was happening due to `null` icons passed to FontAwesomeIcon component in tests resolves #1809
1 parent 2795f51 commit 0a2f054

7 files changed

Lines changed: 218 additions & 19 deletions

File tree

packages/console/src/command-history/CommandHistoryActions.test.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import React from 'react';
22
import { render, screen } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
4+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
5+
import { TestUtils } from '@deephaven/utils';
46
import CommandHistoryActions from './CommandHistoryActions';
57

6-
jest.useFakeTimers();
8+
const { asMock } = TestUtils;
9+
10+
jest.mock('@fortawesome/react-fontawesome', () => ({
11+
FontAwesomeIcon: jest.fn(),
12+
}));
13+
14+
beforeEach(() => {
15+
jest.useFakeTimers();
16+
asMock(FontAwesomeIcon).mockReturnValue(<div>Mock FontAwesomeIcon</div>);
17+
});
18+
19+
afterAll(() => {
20+
jest.useRealTimers();
21+
});
722

823
const toBeClicked = jest.fn();
924
const makeHistoryActionsMock = () => [

packages/jsapi-components/src/usePickerWithSelectedValues.test.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,12 @@ const tableUtils = createMockProxy<TableUtils>();
4141

4242
const mock = {
4343
columnName: 'mock.columnName',
44-
isDebouncingFalse: { isDebouncing: false },
45-
isDebouncingTrue: { isDebouncing: true },
44+
isDebouncingFalse: { isDebouncing: false } as ReturnType<
45+
typeof useDebouncedValue
46+
>,
47+
isDebouncingTrue: { isDebouncing: true } as ReturnType<
48+
typeof useDebouncedValue
49+
>,
4650
filter: [
4751
createMockProxy<FilterCondition>(),
4852
createMockProxy<FilterCondition>(),
@@ -459,16 +463,7 @@ describe('searchTextExists', () => {
459463
// eslint-disable-next-line no-promise-executor-return
460464
const unresolvedPromise = new Promise<boolean>(() => undefined);
461465

462-
it.each([
463-
// isLoading, isDebouncing, exists
464-
// (at least one of `isLoading` or `isDebouncing` is true in all cases)
465-
[true, true, true],
466-
[true, true, false],
467-
[true, false, true],
468-
[true, false, false],
469-
[false, true, true],
470-
[false, true, false],
471-
])(
466+
it.each(TestUtils.generateBooleanCombinations(3))(
472467
'should be null if check is in progress: isLoading:%s, isDebouncing:%s, valueExists:%s',
473468
async (valueExistsIsLoading, isDebouncing, valueExists) => {
474469
asMock(tableUtils.doesColumnValueExist).mockReturnValue(
@@ -477,13 +472,17 @@ describe('searchTextExists', () => {
477472

478473
asMock(useDebouncedValue).mockReturnValue({
479474
isDebouncing,
480-
});
475+
} as ReturnType<typeof useDebouncedValue>);
481476

482477
const { result } = await renderOnceAndWait();
483478

484479
expect(useDebouncedValue).toHaveBeenCalledWith('', SEARCH_DEBOUNCE_MS);
485480

486-
expect(result.current.searchTextExists).toBeNull();
481+
if (valueExistsIsLoading || isDebouncing) {
482+
expect(result.current.searchTextExists).toBeNull();
483+
} else {
484+
expect(result.current.searchTextExists).toEqual(valueExists);
485+
}
487486
}
488487
);
489488

packages/utils/src/MockProxy.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,58 @@ describe('createMockProxy', () => {
8585
});
8686
expect(mock.testMethod).toBeInstanceOf(jest.fn().constructor);
8787
});
88+
89+
it.each([undefined, 'some label'])('should be spreadable: %s', label => {
90+
const overrides = {
91+
name: 'mock.name',
92+
age: 42,
93+
};
94+
95+
const mock = createMockProxy<{
96+
name: string;
97+
age: number;
98+
testMethod: () => void;
99+
}>(overrides, { label });
100+
101+
expect({ ...mock }).toEqual({
102+
[MockProxySymbol.labelSymbol]: label ?? 'Mock Proxy',
103+
name: 'mock.name',
104+
age: 42,
105+
});
106+
});
107+
108+
it.each([undefined, true, false])(
109+
'should include accessed auto proxy props if includeAutoProxiesInOwnKeys is true: %s',
110+
includeAutoProxiesInOwnKeys => {
111+
const overrides = {
112+
name: 'mock.name',
113+
age: 42,
114+
};
115+
116+
const mock = createMockProxy<{
117+
name: string;
118+
age: number;
119+
testMethod: () => void;
120+
}>(overrides, { includeAutoProxiesInOwnKeys });
121+
122+
const expectedBase = {
123+
[MockProxySymbol.labelSymbol]: 'Mock Proxy',
124+
name: 'mock.name',
125+
age: 42,
126+
};
127+
128+
expect({ ...mock }).toEqual(expectedBase);
129+
130+
mock.testMethod();
131+
132+
if (includeAutoProxiesInOwnKeys === true) {
133+
expect({ ...mock }).toEqual({
134+
...expectedBase,
135+
testMethod: expect.any(Function),
136+
});
137+
} else {
138+
expect({ ...mock }).toEqual(expectedBase);
139+
}
140+
}
141+
);
88142
});

packages/utils/src/MockProxy.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
const labelSymbol: unique symbol = Symbol('mockProxyType');
12
const defaultPropsSymbol: unique symbol = Symbol('mockProxyDefaultProps');
23
const overridesSymbol: unique symbol = Symbol('mockProxyOverrides');
34
const proxiesSymbol: unique symbol = Symbol('mockProxyProxies');
45

56
export const MockProxySymbol = {
7+
labelSymbol,
68
defaultProps: defaultPropsSymbol,
79
overrides: overridesSymbol,
810
proxies: proxiesSymbol,
@@ -29,32 +31,53 @@ const mockProxyDefaultProps = {
2931
* The proxy target contains state + configuration for the proxy
3032
*/
3133
export interface MockProxyTarget<T> {
34+
[MockProxySymbol.labelSymbol]: string;
3235
[MockProxySymbol.defaultProps]: typeof mockProxyDefaultProps;
3336
[MockProxySymbol.overrides]: Partial<T>;
3437
[MockProxySymbol.proxies]: Record<keyof T, jest.Mock>;
3538
}
3639

40+
export interface MockProxyConfig {
41+
// Optional label to be assigned to the proxy object's
42+
// `MockProxySymbol.labelSymbol` property.
43+
label?: string;
44+
45+
// `ownKeys` has no way to know all of the potential auto proxy keys, but it
46+
// can know auto proxies that have been called / cached. If this flag is true,
47+
// include those in the `ownKeys` result. This is mostly useful for spread
48+
// operations. Alternatively, the `overrides` are can explicitly include any
49+
// proxies to be included in the `ownKeys` result without setting this flag.
50+
// e.g. createMockProxy({ someMethod: jest.fn() }) would include `someMethod`.
51+
includeAutoProxiesInOwnKeys?: boolean;
52+
}
53+
3754
/**
3855
* Creates a mock object for a type `T` using a Proxy object. Each prop can
3956
* optionally be set via the constructor. Any prop that is not set will be set
4057
* to a jest.fn() instance on first access with the exeption of "then" which
4158
* will not be automatically proxied.
4259
* @param overrides Optional props to explicitly set on the Proxy.
43-
* @returns
60+
* @param config Optional configuration for the proxy.
61+
* @returns A mock Proxy object for type `T`.
4462
*/
4563
export default function createMockProxy<T>(
46-
overrides: Partial<T> = {}
64+
overrides: Partial<T> = {},
65+
{
66+
label = 'Mock Proxy',
67+
includeAutoProxiesInOwnKeys = false,
68+
}: MockProxyConfig = {}
4769
): T & MockProxyTarget<T> {
4870
const targetDef: MockProxyTarget<T> = {
71+
[MockProxySymbol.labelSymbol]: label,
4972
[MockProxySymbol.defaultProps]: mockProxyDefaultProps,
5073
[MockProxySymbol.overrides]: overrides,
5174
[MockProxySymbol.proxies]: {} as Record<keyof T, jest.Mock>,
5275
};
5376

5477
return new Proxy(targetDef, {
5578
get(target, name) {
56-
if (name === Symbol.toStringTag) {
57-
return 'Mock Proxy';
79+
if (name === Symbol.toStringTag || name === MockProxySymbol.labelSymbol) {
80+
return targetDef[MockProxySymbol.labelSymbol];
5881
}
5982

6083
// Reserved attributes for the proxy
@@ -92,5 +115,25 @@ export default function createMockProxy<T>(
92115
has(target, name) {
93116
return name in target[MockProxySymbol.overrides];
94117
},
118+
// Needed to support the spread (...) operator
119+
getOwnPropertyDescriptor(_target, _prop) {
120+
return { configurable: true, enumerable: true };
121+
},
122+
// Needed to support the spread (...) operator
123+
ownKeys(target) {
124+
const autoProxyKeys = includeAutoProxiesInOwnKeys
125+
? Reflect.ownKeys(target[MockProxySymbol.proxies])
126+
: [];
127+
128+
const overridesKeys = Reflect.ownKeys(target[MockProxySymbol.overrides]);
129+
130+
return [
131+
...new Set<string | symbol>([
132+
MockProxySymbol.labelSymbol,
133+
...autoProxyKeys,
134+
...overridesKeys,
135+
]),
136+
];
137+
},
95138
}) as T & typeof targetDef;
96139
}

packages/utils/src/TestUtils.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,40 @@ describe('findLastCall', () => {
7878
});
7979
});
8080

81+
describe('generateBooleanCombinations', () => {
82+
it.each([
83+
[1, [[false], [true]]],
84+
[
85+
2,
86+
[
87+
[false, false],
88+
[false, true],
89+
[true, false],
90+
[true, true],
91+
],
92+
],
93+
[
94+
3,
95+
[
96+
[false, false, false],
97+
[false, false, true],
98+
[false, true, false],
99+
[false, true, true],
100+
[true, false, false],
101+
[true, false, true],
102+
[true, true, false],
103+
[true, true, true],
104+
],
105+
],
106+
])(
107+
'should generate all possible combinations of boolean values',
108+
(n, expected) => {
109+
const result = TestUtils.generateBooleanCombinations(n);
110+
expect(result).toEqual(expected);
111+
}
112+
);
113+
});
114+
81115
describe('makeMockContext', () => {
82116
it('should make a MockContext object', () => {
83117
const mockContext = TestUtils.makeMockContext();

packages/utils/src/TestUtils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type userEvent from '@testing-library/user-event';
22
import createMockProxy from './MockProxy';
3+
import { Tuple } from './TypeUtils';
34

45
interface MockContext {
56
arc: jest.Mock<void>;
@@ -102,6 +103,30 @@ class TestUtils {
102103
): TArgs | undefined =>
103104
TestUtils.asMock(fn).mock.calls.reverse().find(predicate);
104105

106+
/**
107+
* Generate all possible combinations of boolean values for a given number of
108+
* variables
109+
* @param n The number of boolean values to generate combinations for.
110+
* @returns An array of tuples representing all possible combinations
111+
* of boolean values for the given number of values.
112+
*/
113+
static generateBooleanCombinations = <T extends number>(
114+
n: T
115+
): Tuple<boolean, T>[] => {
116+
const combinations = 2 ** n;
117+
const result: Tuple<boolean, T>[] = [];
118+
119+
// eslint-disable-next-line no-plusplus
120+
for (let i = 0; i < combinations; i++) {
121+
// eslint-disable-next-line no-bitwise
122+
const binary = (i >>> 0).toString(2).padStart(n, '0');
123+
const bitArray = Array.from(binary).map(bit => bit === '1');
124+
result.push(bitArray as Tuple<boolean, T>);
125+
}
126+
127+
return result;
128+
};
129+
105130
static makeMockContext(): MockContext {
106131
return {
107132
arc: jest.fn(),

packages/utils/src/TypeUtils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,35 @@ export type OnlyOneProp<T> = {
2727
[P in keyof T]: { [ONEPROP in P]: T[ONEPROP] };
2828
}[keyof T];
2929

30+
/**
31+
* Tuple type.
32+
* @param T Type of the items in the tuple
33+
* @param N Length of the tuple
34+
*/
35+
export type Tuple<T, N extends number> = N extends 0
36+
? []
37+
: N extends 1
38+
? [T]
39+
: N extends 2
40+
? [T, T]
41+
: N extends 3
42+
? [T, T, T]
43+
: N extends 4
44+
? [T, T, T, T]
45+
: N extends 5
46+
? [T, T, T, T, T]
47+
: N extends 6
48+
? [T, T, T, T, T, T]
49+
: N extends 7
50+
? [T, T, T, T, T, T, T]
51+
: N extends 8
52+
? [T, T, T, T, T, T, T, T]
53+
: N extends 9
54+
? [T, T, T, T, T, T, T, T, T]
55+
: N extends 10
56+
? [T, T, T, T, T, T, T, T, T, T]
57+
: Array<T>;
58+
3059
/**
3160
* Remove `Partial` wrapper from a type. Note that this is slightly different
3261
* than `Required` because it will preserve optional properties on the original

0 commit comments

Comments
 (0)