Skip to content

Commit 08e1b12

Browse files
chore: warn on unknown options (#1865)
1 parent df12e51 commit 08e1b12

File tree

10 files changed

+272
-13
lines changed

10 files changed

+272
-13
lines changed

src/__tests__/config.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { configure, getConfig, resetToDefaults } from '../config';
2+
import { _console } from '../helpers/logger';
23

34
beforeEach(() => {
45
resetToDefaults();
6+
jest.spyOn(_console, 'warn').mockImplementation(() => {});
7+
});
8+
9+
afterEach(() => {
10+
jest.restoreAllMocks();
511
});
612

713
test('getConfig() returns existing configuration', () => {
@@ -46,3 +52,29 @@ test('configure handles alias option defaultHidden', () => {
4652
configure({ defaultHidden: true });
4753
expect(getConfig().defaultIncludeHiddenElements).toEqual(true);
4854
});
55+
56+
test('does not warn when no options are passed', () => {
57+
configure({});
58+
59+
expect(_console.warn).not.toHaveBeenCalled();
60+
});
61+
62+
test('does not warn when only valid options are passed', () => {
63+
configure({
64+
asyncUtilTimeout: 2000,
65+
defaultIncludeHiddenElements: true,
66+
defaultDebugOptions: { message: 'test' },
67+
defaultHidden: false,
68+
});
69+
70+
expect(_console.warn).not.toHaveBeenCalled();
71+
});
72+
73+
test('warns when unknown option is passed', () => {
74+
configure({ unknownOption: 'value' } as any);
75+
76+
expect(_console.warn).toHaveBeenCalledTimes(1);
77+
const warningMessage = jest.mocked(_console.warn).mock.calls[0][0];
78+
expect(warningMessage).toContain('Unknown option(s) passed to configure: unknownOption');
79+
expect(warningMessage).toContain('config.test.ts');
80+
});

src/__tests__/render-hook.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ import * as React from 'react';
33
import { Text } from 'react-native';
44

55
import { act, renderHook } from '..';
6+
import { _console } from '../helpers/logger';
67
import { excludeConsoleMessage } from '../test-utils/console';
78

89
// eslint-disable-next-line no-console
910
const originalConsoleError = console.error;
11+
12+
beforeEach(() => {
13+
jest.spyOn(_console, 'warn').mockImplementation(() => {});
14+
});
15+
1016
afterEach(() => {
1117
// eslint-disable-next-line no-console
1218
console.error = originalConsoleError;
19+
jest.restoreAllMocks();
1320
});
1421

1522
function useSuspendingHook(promise: Promise<string>) {
@@ -289,3 +296,42 @@ test('handles custom hooks with complex logic', async () => {
289296
});
290297
expect(result.current.count).toBe(4);
291298
});
299+
300+
test('does not warn when no options are passed', async () => {
301+
function useTestHook() {
302+
return React.useState(0);
303+
}
304+
305+
await renderHook(useTestHook);
306+
307+
expect(_console.warn).not.toHaveBeenCalled();
308+
});
309+
310+
test('does not warn when only valid options are passed', async () => {
311+
const Context = React.createContext('default');
312+
313+
function useTestHook() {
314+
return React.useContext(Context);
315+
}
316+
317+
function Wrapper({ children }: { children: ReactNode }) {
318+
return <Context.Provider value="provided">{children}</Context.Provider>;
319+
}
320+
321+
await renderHook(useTestHook, { wrapper: Wrapper, initialProps: undefined });
322+
323+
expect(_console.warn).not.toHaveBeenCalled();
324+
});
325+
326+
test('warns when unknown option is passed', async () => {
327+
function useTestHook() {
328+
return React.useState(0);
329+
}
330+
331+
await renderHook(useTestHook, { unknownOption: 'value' } as any);
332+
333+
expect(_console.warn).toHaveBeenCalledTimes(1);
334+
expect(jest.mocked(_console.warn).mock.calls[0][0]).toContain(
335+
'Unknown option(s) passed to renderHook: unknownOption',
336+
);
337+
});

src/__tests__/render.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import { Text, View } from 'react-native';
33

44
import { render, screen } from '..';
5-
import { logger } from '../helpers/logger';
5+
import { _console, logger } from '../helpers/logger';
66

77
test('renders a simple component', async () => {
88
const TestComponent = () => (
@@ -17,6 +17,42 @@ test('renders a simple component', async () => {
1717
expect(screen.getByText('Hello World')).toBeOnTheScreen();
1818
});
1919

20+
beforeEach(() => {
21+
jest.spyOn(_console, 'warn').mockImplementation(() => {});
22+
});
23+
24+
afterEach(() => {
25+
jest.restoreAllMocks();
26+
});
27+
28+
test('does not warn when no options are passed', async () => {
29+
const TestComponent = () => <Text testID="test">Test</Text>;
30+
31+
await render(<TestComponent />);
32+
33+
expect(_console.warn).not.toHaveBeenCalled();
34+
});
35+
36+
test('does not warn when only valid options are passed', async () => {
37+
const TestComponent = () => <Text testID="test">Test</Text>;
38+
const Wrapper = ({ children }: { children: React.ReactNode }) => <View>{children}</View>;
39+
40+
await render(<TestComponent />, { wrapper: Wrapper, createNodeMock: jest.fn() });
41+
42+
expect(_console.warn).not.toHaveBeenCalled();
43+
});
44+
45+
test('warns when unknown option is passed', async () => {
46+
const TestComponent = () => <Text testID="test">Test</Text>;
47+
48+
await render(<TestComponent />, { unknownOption: 'value' } as any);
49+
50+
expect(_console.warn).toHaveBeenCalledTimes(1);
51+
expect(jest.mocked(_console.warn).mock.calls[0][0]).toContain(
52+
'Unknown option(s) passed to render: unknownOption',
53+
);
54+
});
55+
2056
describe('render options', () => {
2157
test('renders component with wrapper option', async () => {
2258
const TestComponent = () => <Text testID="inner">Inner Content</Text>;

src/config.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DebugOptions } from './helpers/debug';
2+
import { validateOptions } from './helpers/validate-options';
23

34
/**
45
* Global configuration options for React Native Testing Library.
@@ -31,17 +32,24 @@ let config = { ...defaultConfig };
3132
* Configure global options for React Native Testing Library.
3233
*/
3334
export function configure(options: Partial<Config & ConfigAliasOptions>) {
34-
const { defaultHidden, ...restOptions } = options;
35+
const {
36+
asyncUtilTimeout,
37+
defaultDebugOptions,
38+
defaultHidden,
39+
defaultIncludeHiddenElements,
40+
...rest
41+
} = options;
42+
43+
validateOptions('configure', rest, configure);
3544

36-
const defaultIncludeHiddenElements =
37-
restOptions.defaultIncludeHiddenElements ??
38-
defaultHidden ??
39-
config.defaultIncludeHiddenElements;
45+
const resolvedDefaultIncludeHiddenElements =
46+
defaultIncludeHiddenElements ?? defaultHidden ?? config.defaultIncludeHiddenElements;
4047

4148
config = {
4249
...config,
43-
...restOptions,
44-
defaultIncludeHiddenElements,
50+
asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout,
51+
defaultDebugOptions,
52+
defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements,
4553
};
4654
}
4755

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { _console } from '../logger';
2+
import { validateOptions } from '../validate-options';
3+
4+
function testFunction() {
5+
// Test function for callsite
6+
}
7+
8+
beforeEach(() => {
9+
jest.spyOn(_console, 'warn').mockImplementation(() => {});
10+
});
11+
12+
test('does not warn when rest object is empty', () => {
13+
validateOptions('testFunction', {}, testFunction);
14+
15+
expect(_console.warn).not.toHaveBeenCalled();
16+
});
17+
18+
test('warns when unknown option is passed', () => {
19+
function testFunctionWithCall() {
20+
validateOptions('testFunction', { unknownOption: 'value' }, testFunctionWithCall);
21+
}
22+
testFunctionWithCall();
23+
24+
expect(_console.warn).toHaveBeenCalledTimes(1);
25+
const warningMessage = jest.mocked(_console.warn).mock.calls[0][0];
26+
expect(warningMessage).toContain('Unknown option(s) passed to testFunction: unknownOption');
27+
expect(warningMessage).toContain('validate-options.test.ts');
28+
});
29+
30+
test('warns when multiple unknown options are passed', () => {
31+
function testFunctionWithCall() {
32+
validateOptions(
33+
'testFunction',
34+
{ option1: 'value1', option2: 'value2', option3: 'value3' },
35+
testFunctionWithCall,
36+
);
37+
}
38+
testFunctionWithCall();
39+
40+
expect(_console.warn).toHaveBeenCalledTimes(1);
41+
const warningMessage = jest.mocked(_console.warn).mock.calls[0][0];
42+
expect(warningMessage).toContain(
43+
'Unknown option(s) passed to testFunction: option1, option2, option3',
44+
);
45+
expect(warningMessage).toContain('validate-options.test.ts');
46+
});
47+
48+
test('warns with correct function name and includes stack trace', () => {
49+
function render() {
50+
validateOptions('render', { invalid: true }, render);
51+
}
52+
render();
53+
54+
expect(_console.warn).toHaveBeenCalledTimes(1);
55+
const warningMessage = jest.mocked(_console.warn).mock.calls[0][0];
56+
expect(warningMessage).toContain('render');
57+
expect(warningMessage).toContain('validate-options.test.ts');
58+
});

src/helpers/validate-options.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ErrorWithStack } from './errors';
2+
import { logger } from './logger';
3+
4+
/**
5+
* Validates that no unknown options are passed to a function.
6+
* Logs a warning if unknown options are found.
7+
*
8+
* @param functionName - The name of the function being called (for error messages)
9+
* @param restOptions - The rest object from destructuring that contains unknown options
10+
* @param callsite - The function where the validation is called from (e.g., render, renderHook)
11+
*/
12+
export function validateOptions(
13+
functionName: string,
14+
restOptions: Record<string, unknown>,
15+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
16+
callsite: Function,
17+
): void {
18+
const unknownKeys = Object.keys(restOptions);
19+
if (unknownKeys.length > 0) {
20+
// Pass the callsite function (e.g., render) to remove it from stack
21+
// This leaves only where the user called the function from (e.g., test file)
22+
const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', callsite);
23+
const stackLines = stackTraceError.stack ? stackTraceError.stack.split('\n') : [];
24+
// Skip the first line (Error: STACK_TRACE_ERROR) to show the actual call sites
25+
// The remaining lines show where the user called the function from
26+
const stackTrace = stackLines.length > 1 ? `\n\n${stackLines.slice(1).join('\n')}` : '';
27+
logger.warn(
28+
`Unknown option(s) passed to ${functionName}: ${unknownKeys.join(', ')}${stackTrace}`,
29+
);
30+
}
31+
}

src/render-hook.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22

3+
import { validateOptions } from './helpers/validate-options';
34
import { render } from './render';
45
import type { RefObject } from './types';
56

@@ -38,7 +39,9 @@ export async function renderHook<Result, Props>(
3839
return null;
3940
}
4041

41-
const { initialProps, ...renderOptions } = options ?? {};
42+
const { initialProps, wrapper, ...rest } = options ?? {};
43+
validateOptions('renderHook', rest, renderHook);
44+
const renderOptions = wrapper ? { wrapper } : {};
4245
const { rerender: rerenderComponent, unmount } = await render(
4346
// @ts-expect-error since option can be undefined, initialProps can be undefined when it shouldn't be
4447
<HookContainer hookProps={initialProps} />,

src/render.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getConfig } from './config';
1313
import type { DebugOptions } from './helpers/debug';
1414
import { debug } from './helpers/debug';
1515
import { HOST_TEXT_NAMES } from './helpers/host-component-names';
16+
import { validateOptions } from './helpers/validate-options';
1617
import { setRenderResult } from './screen';
1718
import { getQueriesForElement } from './within';
1819

@@ -34,7 +35,8 @@ export type RenderResult = Awaited<ReturnType<typeof render>>;
3435
* to assert on the output.
3536
*/
3637
export async function render<T>(element: React.ReactElement<T>, options: RenderOptions = {}) {
37-
const { wrapper: Wrapper, createNodeMock } = options || {};
38+
const { wrapper: Wrapper, createNodeMock, ...rest } = options || {};
39+
validateOptions('render', rest, render);
3840

3941
const rendererOptions: RootOptions = {
4042
textComponentTypes: HOST_TEXT_NAMES,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { _console } from '../../../helpers/logger';
2+
import { setup } from '../setup';
3+
4+
beforeEach(() => {
5+
jest.spyOn(_console, 'warn').mockImplementation(() => {});
6+
});
7+
8+
afterEach(() => {
9+
jest.restoreAllMocks();
10+
});
11+
12+
test('creates instance when no options are passed', () => {
13+
const instance = setup();
14+
15+
expect(instance).toBeDefined();
16+
expect(_console.warn).not.toHaveBeenCalled();
17+
});
18+
19+
test('creates instance with valid options', () => {
20+
const instance = setup({ delay: 100, advanceTimers: jest.fn() });
21+
22+
expect(instance).toBeDefined();
23+
expect(instance.config.delay).toBe(100);
24+
expect(_console.warn).not.toHaveBeenCalled();
25+
});
26+
27+
test('warns when unknown option is passed', () => {
28+
setup({ unknownOption: 'value' } as any);
29+
30+
expect(_console.warn).toHaveBeenCalledTimes(1);
31+
expect(jest.mocked(_console.warn).mock.calls[0][0]).toContain(
32+
'Unknown option(s) passed to userEvent.setup: unknownOption',
33+
);
34+
});

src/user-event/setup/setup.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { HostElement } from 'test-renderer';
22

33
import { jestFakeTimersAreEnabled } from '../../helpers/timers';
4+
import { validateOptions } from '../../helpers/validate-options';
45
import { wrapAsync } from '../../helpers/wrap-async';
56
import { clear } from '../clear';
67
import { paste } from '../paste';
@@ -57,7 +58,7 @@ const defaultOptions: Required<UserEventSetupOptions> = {
5758
* @returns UserEvent instance
5859
*/
5960
export function setup(options?: UserEventSetupOptions) {
60-
const config = createConfig(options);
61+
const config = createConfig(options, setup);
6162
const instance = createInstance(config);
6263
return instance;
6364
}
@@ -73,10 +74,18 @@ export interface UserEventConfig {
7374
advanceTimers: (delay: number) => Promise<void> | void;
7475
}
7576

76-
function createConfig(options?: UserEventSetupOptions): UserEventConfig {
77+
function createConfig(
78+
options: UserEventSetupOptions = {},
79+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
80+
callsite: Function,
81+
): UserEventConfig {
82+
const { delay, advanceTimers, ...rest } = options;
83+
validateOptions('userEvent.setup', rest, callsite);
84+
7785
return {
7886
...defaultOptions,
79-
...options,
87+
...(delay !== undefined && { delay }),
88+
...(advanceTimers !== undefined && { advanceTimers }),
8089
};
8190
}
8291

0 commit comments

Comments
 (0)