Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
46 changes: 46 additions & 0 deletions src/__tests__/render-hook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
Expand Down Expand Up @@ -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 <Context.Provider value="provided">{children}</Context.Provider>;
}

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',
);
});
38 changes: 37 additions & 1 deletion src/__tests__/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
Expand All @@ -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 = () => <Text testID="test">Test</Text>;

await render(<TestComponent />);

expect(_console.warn).not.toHaveBeenCalled();
});

test('does not warn when only valid options are passed', async () => {
const TestComponent = () => <Text testID="test">Test</Text>;
const Wrapper = ({ children }: { children: React.ReactNode }) => <View>{children}</View>;

await render(<TestComponent />, { wrapper: Wrapper, createNodeMock: jest.fn() });

expect(_console.warn).not.toHaveBeenCalled();
});

test('warns when unknown option is passed', async () => {
const TestComponent = () => <Text testID="test">Test</Text>;

await render(<TestComponent />, { 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 = () => <Text testID="inner">Inner Content</Text>;
Expand Down
22 changes: 15 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { DebugOptions } from './helpers/debug';
import { validateOptions } from './helpers/validate-options';

/**
* Global configuration options for React Native Testing Library.
Expand Down Expand Up @@ -31,17 +32,24 @@ let config = { ...defaultConfig };
* Configure global options for React Native Testing Library.
*/
export function configure(options: Partial<Config & ConfigAliasOptions>) {
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,
};
}

Expand Down
58 changes: 58 additions & 0 deletions src/helpers/__tests__/validate-options.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
31 changes: 31 additions & 0 deletions src/helpers/validate-options.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
// 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}`,
);
}
}
5 changes: 4 additions & 1 deletion src/render-hook.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';

import { validateOptions } from './helpers/validate-options';
import { render } from './render';
import type { RefObject } from './types';

Expand Down Expand Up @@ -38,7 +39,9 @@ export async function renderHook<Result, Props>(
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
<HookContainer hookProps={initialProps} />,
Expand Down
4 changes: 3 additions & 1 deletion src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

const rendererOptions: RootOptions = {
textComponentTypes: HOST_TEXT_NAMES,
Expand Down
34 changes: 34 additions & 0 deletions src/user-event/setup/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
15 changes: 12 additions & 3 deletions src/user-event/setup/setup.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -57,7 +58,7 @@ const defaultOptions: Required<UserEventSetupOptions> = {
* @returns UserEvent instance
*/
export function setup(options?: UserEventSetupOptions) {
const config = createConfig(options);
const config = createConfig(options, setup);
const instance = createInstance(config);
return instance;
}
Expand All @@ -73,10 +74,18 @@ export interface UserEventConfig {
advanceTimers: (delay: number) => Promise<void> | 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 }),
};
}

Expand Down