diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fa18b9be8..6949f3a6a 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -1,7 +1,13 @@ import { configure, getConfig, resetToDefaults } from '../config'; +import { _console } from '../helpers/logger'; beforeEach(() => { resetToDefaults(); + jest.spyOn(_console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); }); test('getConfig() returns existing configuration', () => { @@ -46,3 +52,29 @@ test('configure handles alias option defaultHidden', () => { configure({ defaultHidden: true }); expect(getConfig().defaultIncludeHiddenElements).toEqual(true); }); + +test('does not warn when no options are passed', () => { + configure({}); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('does not warn when only valid options are passed', () => { + configure({ + asyncUtilTimeout: 2000, + defaultIncludeHiddenElements: true, + defaultDebugOptions: { message: 'test' }, + defaultHidden: false, + }); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('warns when unknown option is passed', () => { + configure({ unknownOption: 'value' } as any); + + expect(_console.warn).toHaveBeenCalledTimes(1); + const warningMessage = jest.mocked(_console.warn).mock.calls[0][0]; + expect(warningMessage).toContain('Unknown option(s) passed to configure: unknownOption'); + expect(warningMessage).toContain('config.test.ts'); +}); diff --git a/src/__tests__/render-hook.test.tsx b/src/__tests__/render-hook.test.tsx index ca1f3f828..6a926cc13 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -3,13 +3,20 @@ import * as React from 'react'; import { Text } from 'react-native'; import { act, renderHook } from '..'; +import { _console } from '../helpers/logger'; import { excludeConsoleMessage } from '../test-utils/console'; // eslint-disable-next-line no-console const originalConsoleError = console.error; + +beforeEach(() => { + jest.spyOn(_console, 'warn').mockImplementation(() => {}); +}); + afterEach(() => { // eslint-disable-next-line no-console console.error = originalConsoleError; + jest.restoreAllMocks(); }); function useSuspendingHook(promise: Promise) { @@ -289,3 +296,42 @@ test('handles custom hooks with complex logic', async () => { }); expect(result.current.count).toBe(4); }); + +test('does not warn when no options are passed', async () => { + function useTestHook() { + return React.useState(0); + } + + await renderHook(useTestHook); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('does not warn when only valid options are passed', async () => { + const Context = React.createContext('default'); + + function useTestHook() { + return React.useContext(Context); + } + + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + await renderHook(useTestHook, { wrapper: Wrapper, initialProps: undefined }); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('warns when unknown option is passed', async () => { + function useTestHook() { + return React.useState(0); + } + + await renderHook(useTestHook, { unknownOption: 'value' } as any); + + expect(_console.warn).toHaveBeenCalledTimes(1); + expect(jest.mocked(_console.warn).mock.calls[0][0]).toContain( + 'Unknown option(s) passed to renderHook: unknownOption', + ); +}); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 08dc4c6b0..6587f7d3e 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { render, screen } from '..'; -import { logger } from '../helpers/logger'; +import { _console, logger } from '../helpers/logger'; test('renders a simple component', async () => { const TestComponent = () => ( @@ -17,6 +17,42 @@ test('renders a simple component', async () => { expect(screen.getByText('Hello World')).toBeOnTheScreen(); }); +beforeEach(() => { + jest.spyOn(_console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('does not warn when no options are passed', async () => { + const TestComponent = () => Test; + + await render(); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('does not warn when only valid options are passed', async () => { + const TestComponent = () => Test; + const Wrapper = ({ children }: { children: React.ReactNode }) => {children}; + + await render(, { wrapper: Wrapper, createNodeMock: jest.fn() }); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('warns when unknown option is passed', async () => { + const TestComponent = () => Test; + + await render(, { unknownOption: 'value' } as any); + + expect(_console.warn).toHaveBeenCalledTimes(1); + expect(jest.mocked(_console.warn).mock.calls[0][0]).toContain( + 'Unknown option(s) passed to render: unknownOption', + ); +}); + describe('render options', () => { test('renders component with wrapper option', async () => { const TestComponent = () => Inner Content; diff --git a/src/config.ts b/src/config.ts index 121e33bc4..b910b40d8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import type { DebugOptions } from './helpers/debug'; +import { validateOptions } from './helpers/validate-options'; /** * Global configuration options for React Native Testing Library. @@ -31,17 +32,24 @@ let config = { ...defaultConfig }; * Configure global options for React Native Testing Library. */ export function configure(options: Partial) { - const { defaultHidden, ...restOptions } = options; + const { + asyncUtilTimeout, + defaultDebugOptions, + defaultHidden, + defaultIncludeHiddenElements, + ...rest + } = options; + + validateOptions('configure', rest, configure); - const defaultIncludeHiddenElements = - restOptions.defaultIncludeHiddenElements ?? - defaultHidden ?? - config.defaultIncludeHiddenElements; + const resolvedDefaultIncludeHiddenElements = + defaultIncludeHiddenElements ?? defaultHidden ?? config.defaultIncludeHiddenElements; config = { ...config, - ...restOptions, - defaultIncludeHiddenElements, + asyncUtilTimeout: asyncUtilTimeout ?? config.asyncUtilTimeout, + defaultDebugOptions, + defaultIncludeHiddenElements: resolvedDefaultIncludeHiddenElements, }; } diff --git a/src/helpers/__tests__/validate-options.test.ts b/src/helpers/__tests__/validate-options.test.ts new file mode 100644 index 000000000..8fd26f160 --- /dev/null +++ b/src/helpers/__tests__/validate-options.test.ts @@ -0,0 +1,58 @@ +import { _console } from '../logger'; +import { validateOptions } from '../validate-options'; + +function testFunction() { + // Test function for callsite +} + +beforeEach(() => { + jest.spyOn(_console, 'warn').mockImplementation(() => {}); +}); + +test('does not warn when rest object is empty', () => { + validateOptions('testFunction', {}, testFunction); + + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('warns when unknown option is passed', () => { + function testFunctionWithCall() { + validateOptions('testFunction', { unknownOption: 'value' }, testFunctionWithCall); + } + testFunctionWithCall(); + + expect(_console.warn).toHaveBeenCalledTimes(1); + const warningMessage = jest.mocked(_console.warn).mock.calls[0][0]; + expect(warningMessage).toContain('Unknown option(s) passed to testFunction: unknownOption'); + expect(warningMessage).toContain('validate-options.test.ts'); +}); + +test('warns when multiple unknown options are passed', () => { + function testFunctionWithCall() { + validateOptions( + 'testFunction', + { option1: 'value1', option2: 'value2', option3: 'value3' }, + testFunctionWithCall, + ); + } + testFunctionWithCall(); + + expect(_console.warn).toHaveBeenCalledTimes(1); + const warningMessage = jest.mocked(_console.warn).mock.calls[0][0]; + expect(warningMessage).toContain( + 'Unknown option(s) passed to testFunction: option1, option2, option3', + ); + expect(warningMessage).toContain('validate-options.test.ts'); +}); + +test('warns with correct function name and includes stack trace', () => { + function render() { + validateOptions('render', { invalid: true }, render); + } + render(); + + expect(_console.warn).toHaveBeenCalledTimes(1); + const warningMessage = jest.mocked(_console.warn).mock.calls[0][0]; + expect(warningMessage).toContain('render'); + expect(warningMessage).toContain('validate-options.test.ts'); +}); diff --git a/src/helpers/validate-options.ts b/src/helpers/validate-options.ts new file mode 100644 index 000000000..2748d7311 --- /dev/null +++ b/src/helpers/validate-options.ts @@ -0,0 +1,31 @@ +import { ErrorWithStack } from './errors'; +import { logger } from './logger'; + +/** + * Validates that no unknown options are passed to a function. + * Logs a warning if unknown options are found. + * + * @param functionName - The name of the function being called (for error messages) + * @param restOptions - The rest object from destructuring that contains unknown options + * @param callsite - The function where the validation is called from (e.g., render, renderHook) + */ +export function validateOptions( + functionName: string, + restOptions: Record, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): void { + const unknownKeys = Object.keys(restOptions); + if (unknownKeys.length > 0) { + // Pass the callsite function (e.g., render) to remove it from stack + // This leaves only where the user called the function from (e.g., test file) + const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', callsite); + const stackLines = stackTraceError.stack ? stackTraceError.stack.split('\n') : []; + // Skip the first line (Error: STACK_TRACE_ERROR) to show the actual call sites + // The remaining lines show where the user called the function from + const stackTrace = stackLines.length > 1 ? `\n\n${stackLines.slice(1).join('\n')}` : ''; + logger.warn( + `Unknown option(s) passed to ${functionName}: ${unknownKeys.join(', ')}${stackTrace}`, + ); + } +} diff --git a/src/render-hook.tsx b/src/render-hook.tsx index 564530c35..8a7928b03 100644 --- a/src/render-hook.tsx +++ b/src/render-hook.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { validateOptions } from './helpers/validate-options'; import { render } from './render'; import type { RefObject } from './types'; @@ -38,7 +39,9 @@ export async function renderHook( return null; } - const { initialProps, ...renderOptions } = options ?? {}; + const { initialProps, wrapper, ...rest } = options ?? {}; + validateOptions('renderHook', rest, renderHook); + const renderOptions = wrapper ? { wrapper } : {}; const { rerender: rerenderComponent, unmount } = await render( // @ts-expect-error since option can be undefined, initialProps can be undefined when it shouldn't be , diff --git a/src/render.tsx b/src/render.tsx index a98d519a0..6bd777e56 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -13,6 +13,7 @@ import { getConfig } from './config'; import type { DebugOptions } from './helpers/debug'; import { debug } from './helpers/debug'; import { HOST_TEXT_NAMES } from './helpers/host-component-names'; +import { validateOptions } from './helpers/validate-options'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; @@ -34,7 +35,8 @@ export type RenderResult = Awaited>; * to assert on the output. */ export async function render(element: React.ReactElement, options: RenderOptions = {}) { - const { wrapper: Wrapper, createNodeMock } = options || {}; + const { wrapper: Wrapper, createNodeMock, ...rest } = options || {}; + validateOptions('render', rest, render); const rendererOptions: RootOptions = { textComponentTypes: HOST_TEXT_NAMES, diff --git a/src/user-event/setup/__tests__/setup.test.ts b/src/user-event/setup/__tests__/setup.test.ts new file mode 100644 index 000000000..1bbfa38a1 --- /dev/null +++ b/src/user-event/setup/__tests__/setup.test.ts @@ -0,0 +1,34 @@ +import { _console } from '../../../helpers/logger'; +import { setup } from '../setup'; + +beforeEach(() => { + jest.spyOn(_console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('creates instance when no options are passed', () => { + const instance = setup(); + + expect(instance).toBeDefined(); + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('creates instance with valid options', () => { + const instance = setup({ delay: 100, advanceTimers: jest.fn() }); + + expect(instance).toBeDefined(); + expect(instance.config.delay).toBe(100); + expect(_console.warn).not.toHaveBeenCalled(); +}); + +test('warns when unknown option is passed', () => { + setup({ unknownOption: 'value' } as any); + + expect(_console.warn).toHaveBeenCalledTimes(1); + expect(jest.mocked(_console.warn).mock.calls[0][0]).toContain( + 'Unknown option(s) passed to userEvent.setup: unknownOption', + ); +}); diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index dfec333fc..e48b931b3 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -1,6 +1,7 @@ import type { HostElement } from 'test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; +import { validateOptions } from '../../helpers/validate-options'; import { wrapAsync } from '../../helpers/wrap-async'; import { clear } from '../clear'; import { paste } from '../paste'; @@ -57,7 +58,7 @@ const defaultOptions: Required = { * @returns UserEvent instance */ export function setup(options?: UserEventSetupOptions) { - const config = createConfig(options); + const config = createConfig(options, setup); const instance = createInstance(config); return instance; } @@ -73,10 +74,18 @@ export interface UserEventConfig { advanceTimers: (delay: number) => Promise | void; } -function createConfig(options?: UserEventSetupOptions): UserEventConfig { +function createConfig( + options: UserEventSetupOptions = {}, + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + callsite: Function, +): UserEventConfig { + const { delay, advanceTimers, ...rest } = options; + validateOptions('userEvent.setup', rest, callsite); + return { ...defaultOptions, - ...options, + ...(delay !== undefined && { delay }), + ...(advanceTimers !== undefined && { advanceTimers }), }; }