diff --git a/src/__tests__/event-handler.test.tsx b/src/__tests__/event-handler.test.tsx new file mode 100644 index 000000000..2b4eb577e --- /dev/null +++ b/src/__tests__/event-handler.test.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; + +import { render, screen } from '..'; +import { getEventHandler } from '../event-handler'; + +test('getEventHandler strict mode', () => { + const onPress = jest.fn(); + const testOnlyOnPress = jest.fn(); + + render( + + + {/* @ts-expect-error Intentionally passing such props */} + + {/* @ts-expect-error Intentionally passing such props */} + + , + ); + + const regular = screen.getByTestId('regular'); + const testOnly = screen.getByTestId('testOnly'); + const both = screen.getByTestId('both'); + + expect(getEventHandler(regular, 'press')).toBe(onPress); + expect(getEventHandler(testOnly, 'press')).toBe(testOnlyOnPress); + expect(getEventHandler(both, 'press')).toBe(onPress); + + expect(getEventHandler(regular, 'onPress')).toBe(undefined); + expect(getEventHandler(testOnly, 'onPress')).toBe(undefined); + expect(getEventHandler(both, 'onPress')).toBe(undefined); +}); + +test('getEventHandler loose mode', () => { + const onPress = jest.fn(); + const testOnlyOnPress = jest.fn(); + + render( + + + {/* @ts-expect-error Intentionally passing such props */} + + {/* @ts-expect-error Intentionally passing such props */} + + , + ); + + const regular = screen.getByTestId('regular'); + const testOnly = screen.getByTestId('testOnly'); + const both = screen.getByTestId('both'); + + expect(getEventHandler(regular, 'press', { loose: true })).toBe(onPress); + expect(getEventHandler(testOnly, 'press', { loose: true })).toBe(testOnlyOnPress); + expect(getEventHandler(both, 'press', { loose: true })).toBe(onPress); + + expect(getEventHandler(regular, 'onPress', { loose: true })).toBe(onPress); + expect(getEventHandler(testOnly, 'onPress', { loose: true })).toBe(testOnlyOnPress); + expect(getEventHandler(both, 'onPress', { loose: true })).toBe(onPress); +}); diff --git a/src/event-handler.ts b/src/event-handler.ts new file mode 100644 index 000000000..8f275c6b4 --- /dev/null +++ b/src/event-handler.ts @@ -0,0 +1,39 @@ +import type { ReactTestInstance } from 'react-test-renderer'; + +export type EventHandlerOptions = { + /** Include check for event handler named without adding `on*` prefix. */ + loose?: boolean; +}; + +export function getEventHandler( + element: ReactTestInstance, + eventName: string, + options?: EventHandlerOptions, +) { + const handlerName = getEventHandlerName(eventName); + if (typeof element.props[handlerName] === 'function') { + return element.props[handlerName]; + } + + if (options?.loose && typeof element.props[eventName] === 'function') { + return element.props[eventName]; + } + + if (typeof element.props[`testOnly_${handlerName}`] === 'function') { + return element.props[`testOnly_${handlerName}`]; + } + + if (options?.loose && typeof element.props[`testOnly_${eventName}`] === 'function') { + return element.props[`testOnly_${eventName}`]; + } + + return undefined; +} + +export function getEventHandlerName(eventName: string) { + return `on${capitalizeFirstLetter(eventName)}`; +} + +function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/fire-event.ts b/src/fire-event.ts index 224776618..a843fad09 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -8,6 +8,7 @@ import type { import type { ReactTestInstance } from 'react-test-renderer'; import act from './act'; +import { getEventHandler } from './event-handler'; import { isElementMounted, isHostElement } from './helpers/component-tree'; import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; @@ -80,7 +81,7 @@ function findEventHandler( ): EventHandler | null { const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder; - const handler = getEventHandler(element, eventName); + const handler = getEventHandler(element, eventName, { loose: true }); if (handler && isEventEnabled(element, eventName, touchResponder)) return handler; // eslint-disable-next-line @typescript-eslint/prefer-optional-chain @@ -91,23 +92,6 @@ function findEventHandler( return findEventHandler(element.parent, eventName, touchResponder); } -function getEventHandler(element: ReactTestInstance, eventName: string) { - const eventHandlerName = getEventHandlerName(eventName); - if (typeof element.props[eventHandlerName] === 'function') { - return element.props[eventHandlerName]; - } - - if (typeof element.props[eventName] === 'function') { - return element.props[eventName]; - } - - return undefined; -} - -function getEventHandlerName(eventName: string) { - return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`; -} - // String union type of keys of T that start with on, stripped of 'on' type EventNameExtractor = keyof { [K in keyof T as K extends `on${infer Rest}` ? Uncapitalize : never]: T[K]; diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index ec5e1fd80..3f04fb31d 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -1,6 +1,7 @@ import type { ReactTestInstance } from 'react-test-renderer'; import act from '../../act'; +import { getEventHandler } from '../../event-handler'; import { isElementMounted } from '../../helpers/component-tree'; /** @@ -25,17 +26,3 @@ export function dispatchEvent(element: ReactTestInstance, eventName: string, ... handler(...event); }); } - -function getEventHandler(element: ReactTestInstance, eventName: string) { - const handleName = getEventHandlerName(eventName); - const handle = element.props[handleName] as unknown; - if (typeof handle !== 'function') { - return undefined; - } - - return handle; -} - -function getEventHandlerName(eventName: string) { - return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`; -} diff --git a/website/docs/12.x/docs/api.md b/website/docs/12.x/docs/api.md index ef91c3ae8..ced5fd96a 100644 --- a/website/docs/12.x/docs/api.md +++ b/website/docs/12.x/docs/api.md @@ -1,6 +1,7 @@ --- uri: /api --- + # API Overview React Native Testing Library consists of following APIs: @@ -12,10 +13,9 @@ React Native Testing Library consists of following APIs: - Helpers: [`debug`](docs/api/screen#debug), [`toJSON`](docs/api/screen#tojson), [`root`](docs/api/screen#root) - [Jest matchers](docs/api/jest-matchers) - validate assumptions about your UI - [User Event](docs/api/events/user-event) - simulate common user interactions like [`press`](docs/api/events/user-event#press) or [`type`](docs/api/events/user-event#type) in a realistic way -- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way -purposes +- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way purposes - Misc APIs: - - [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing + - [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing - [Async utils](docs/api/misc/async): `findBy*` queries, `wait`, `waitForElementToBeRemoved` - [Configuration](docs/api/misc/config): `configure`, `resetToDefaults` - [Accessibility](docs/api/misc/accessibility): `isHiddenFromAccessibility` diff --git a/website/docs/13.x/docs/advanced/_meta.json b/website/docs/13.x/docs/advanced/_meta.json index 32b909fa0..1449c6c52 100644 --- a/website/docs/13.x/docs/advanced/_meta.json +++ b/website/docs/13.x/docs/advanced/_meta.json @@ -1 +1 @@ -["testing-env", "understanding-act"] +["testing-env", "understanding-act", "third-party-integration"] diff --git a/website/docs/13.x/docs/advanced/third-party-integration.mdx b/website/docs/13.x/docs/advanced/third-party-integration.mdx new file mode 100644 index 000000000..4cf9ba025 --- /dev/null +++ b/website/docs/13.x/docs/advanced/third-party-integration.mdx @@ -0,0 +1,39 @@ +# Third-Party Library Integration + +The React Native Testing Library is designed to simulate the core behaviors of React Native. However, it does not replicate the internal logic of third-party libraries. This guide explains how to integrate your library with RNTL. + +## Handling Events in Third-Party Libraries + +RNTL provides two subsystems to simulate events: + +- **Fire Event**: A lightweight simulation system that can trigger event handlers defined on both host and composite components. +- **User Event**: A more realistic interaction simulation system that can trigger event handlers defined only on host components. + +In many third-party libraries, event handling involves native code, which means RNTL cannot fully simulate the event flow, as it runs only JavaScript code. To address this limitation, you can use `testOnly_on*` props on host components to expose custom events to RNTL’s event subsystems. Both subsystems will first attempt to locate the standard `on*` event handlers; if these are not available, they fall back to the `testOnly_on*` handlers. + +### Example: React Native Gesture Handler + +React Native Gesture Handler (RNGH) provides a composite [Pressable](https://docs.swmansion.com/react-native-gesture-handler/docs/components/pressable/) component with `onPress*` props. These event handlers are not exposed on the rendered host views; instead, they are invoked via RNGH’s internal event flow, which involves native modules. As a result, they are not accessible to RNTL’s event subsystems. + +To enable RNTL to interact with RNGH’s `Pressable` component, the library exposes `testOnly_onPress*` props on the `NativeButton` host component rendered by `Pressable`. This adjustment allows RNTL to simulate interactions during testing. + +```tsx title="Simplified RNGH Pressable component" +function Pressable({ onPress, onPressIn, onPressOut, onLongPress, ... }) { + + // Component logic... + + const isTestEnv = process.env.NODE_ENV === 'test'; + + return ( + + + + ); +} +``` diff --git a/website/docs/13.x/docs/api.md b/website/docs/13.x/docs/api.md index ef91c3ae8..ced5fd96a 100644 --- a/website/docs/13.x/docs/api.md +++ b/website/docs/13.x/docs/api.md @@ -1,6 +1,7 @@ --- uri: /api --- + # API Overview React Native Testing Library consists of following APIs: @@ -12,10 +13,9 @@ React Native Testing Library consists of following APIs: - Helpers: [`debug`](docs/api/screen#debug), [`toJSON`](docs/api/screen#tojson), [`root`](docs/api/screen#root) - [Jest matchers](docs/api/jest-matchers) - validate assumptions about your UI - [User Event](docs/api/events/user-event) - simulate common user interactions like [`press`](docs/api/events/user-event#press) or [`type`](docs/api/events/user-event#type) in a realistic way -- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way -purposes +- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way purposes - Misc APIs: - - [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing + - [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing - [Async utils](docs/api/misc/async): `findBy*` queries, `wait`, `waitForElementToBeRemoved` - [Configuration](docs/api/misc/config): `configure`, `resetToDefaults` - [Accessibility](docs/api/misc/accessibility): `isHiddenFromAccessibility`