Skip to content

Commit c47220b

Browse files
feat: testOnly events (#1741)
1 parent 315afca commit c47220b

File tree

8 files changed

+147
-39
lines changed

8 files changed

+147
-39
lines changed

src/__tests__/event-handler.test.tsx

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from 'react';
2+
import { Text, View } from 'react-native';
3+
4+
import { render, screen } from '..';
5+
import { getEventHandler } from '../event-handler';
6+
7+
test('getEventHandler strict mode', () => {
8+
const onPress = jest.fn();
9+
const testOnlyOnPress = jest.fn();
10+
11+
render(
12+
<View>
13+
<Text testID="regular" onPress={onPress} />
14+
{/* @ts-expect-error Intentionally passing such props */}
15+
<View testID="testOnly" testOnly_onPress={testOnlyOnPress} />
16+
{/* @ts-expect-error Intentionally passing such props */}
17+
<View testID="both" onPress={onPress} testOnly_onPress={testOnlyOnPress} />
18+
</View>,
19+
);
20+
21+
const regular = screen.getByTestId('regular');
22+
const testOnly = screen.getByTestId('testOnly');
23+
const both = screen.getByTestId('both');
24+
25+
expect(getEventHandler(regular, 'press')).toBe(onPress);
26+
expect(getEventHandler(testOnly, 'press')).toBe(testOnlyOnPress);
27+
expect(getEventHandler(both, 'press')).toBe(onPress);
28+
29+
expect(getEventHandler(regular, 'onPress')).toBe(undefined);
30+
expect(getEventHandler(testOnly, 'onPress')).toBe(undefined);
31+
expect(getEventHandler(both, 'onPress')).toBe(undefined);
32+
});
33+
34+
test('getEventHandler loose mode', () => {
35+
const onPress = jest.fn();
36+
const testOnlyOnPress = jest.fn();
37+
38+
render(
39+
<View>
40+
<Text testID="regular" onPress={onPress} />
41+
{/* @ts-expect-error Intentionally passing such props */}
42+
<View testID="testOnly" testOnly_onPress={testOnlyOnPress} />
43+
{/* @ts-expect-error Intentionally passing such props */}
44+
<View testID="both" onPress={onPress} testOnly_onPress={testOnlyOnPress} />
45+
</View>,
46+
);
47+
48+
const regular = screen.getByTestId('regular');
49+
const testOnly = screen.getByTestId('testOnly');
50+
const both = screen.getByTestId('both');
51+
52+
expect(getEventHandler(regular, 'press', { loose: true })).toBe(onPress);
53+
expect(getEventHandler(testOnly, 'press', { loose: true })).toBe(testOnlyOnPress);
54+
expect(getEventHandler(both, 'press', { loose: true })).toBe(onPress);
55+
56+
expect(getEventHandler(regular, 'onPress', { loose: true })).toBe(onPress);
57+
expect(getEventHandler(testOnly, 'onPress', { loose: true })).toBe(testOnlyOnPress);
58+
expect(getEventHandler(both, 'onPress', { loose: true })).toBe(onPress);
59+
});

src/event-handler.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { ReactTestInstance } from 'react-test-renderer';
2+
3+
export type EventHandlerOptions = {
4+
/** Include check for event handler named without adding `on*` prefix. */
5+
loose?: boolean;
6+
};
7+
8+
export function getEventHandler(
9+
element: ReactTestInstance,
10+
eventName: string,
11+
options?: EventHandlerOptions,
12+
) {
13+
const handlerName = getEventHandlerName(eventName);
14+
if (typeof element.props[handlerName] === 'function') {
15+
return element.props[handlerName];
16+
}
17+
18+
if (options?.loose && typeof element.props[eventName] === 'function') {
19+
return element.props[eventName];
20+
}
21+
22+
if (typeof element.props[`testOnly_${handlerName}`] === 'function') {
23+
return element.props[`testOnly_${handlerName}`];
24+
}
25+
26+
if (options?.loose && typeof element.props[`testOnly_${eventName}`] === 'function') {
27+
return element.props[`testOnly_${eventName}`];
28+
}
29+
30+
return undefined;
31+
}
32+
33+
export function getEventHandlerName(eventName: string) {
34+
return `on${capitalizeFirstLetter(eventName)}`;
35+
}
36+
37+
function capitalizeFirstLetter(str: string) {
38+
return str.charAt(0).toUpperCase() + str.slice(1);
39+
}

src/fire-event.ts

+2-18
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import type { ReactTestInstance } from 'react-test-renderer';
99

1010
import act from './act';
11+
import { getEventHandler } from './event-handler';
1112
import { isElementMounted, isHostElement } from './helpers/component-tree';
1213
import { isHostScrollView, isHostTextInput } from './helpers/host-component-names';
1314
import { isPointerEventEnabled } from './helpers/pointer-events';
@@ -80,7 +81,7 @@ function findEventHandler(
8081
): EventHandler | null {
8182
const touchResponder = isTouchResponder(element) ? element : nearestTouchResponder;
8283

83-
const handler = getEventHandler(element, eventName);
84+
const handler = getEventHandler(element, eventName, { loose: true });
8485
if (handler && isEventEnabled(element, eventName, touchResponder)) return handler;
8586

8687
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
@@ -91,23 +92,6 @@ function findEventHandler(
9192
return findEventHandler(element.parent, eventName, touchResponder);
9293
}
9394

94-
function getEventHandler(element: ReactTestInstance, eventName: string) {
95-
const eventHandlerName = getEventHandlerName(eventName);
96-
if (typeof element.props[eventHandlerName] === 'function') {
97-
return element.props[eventHandlerName];
98-
}
99-
100-
if (typeof element.props[eventName] === 'function') {
101-
return element.props[eventName];
102-
}
103-
104-
return undefined;
105-
}
106-
107-
function getEventHandlerName(eventName: string) {
108-
return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`;
109-
}
110-
11195
// String union type of keys of T that start with on, stripped of 'on'
11296
type EventNameExtractor<T> = keyof {
11397
[K in keyof T as K extends `on${infer Rest}` ? Uncapitalize<Rest> : never]: T[K];

src/user-event/utils/dispatch-event.ts

+1-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ReactTestInstance } from 'react-test-renderer';
22

33
import act from '../../act';
4+
import { getEventHandler } from '../../event-handler';
45
import { isElementMounted } from '../../helpers/component-tree';
56

67
/**
@@ -25,17 +26,3 @@ export function dispatchEvent(element: ReactTestInstance, eventName: string, ...
2526
handler(...event);
2627
});
2728
}
28-
29-
function getEventHandler(element: ReactTestInstance, eventName: string) {
30-
const handleName = getEventHandlerName(eventName);
31-
const handle = element.props[handleName] as unknown;
32-
if (typeof handle !== 'function') {
33-
return undefined;
34-
}
35-
36-
return handle;
37-
}
38-
39-
function getEventHandlerName(eventName: string) {
40-
return `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`;
41-
}

website/docs/12.x/docs/api.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
uri: /api
33
---
4+
45
# API Overview
56

67
React Native Testing Library consists of following APIs:
@@ -12,10 +13,9 @@ React Native Testing Library consists of following APIs:
1213
- Helpers: [`debug`](docs/api/screen#debug), [`toJSON`](docs/api/screen#tojson), [`root`](docs/api/screen#root)
1314
- [Jest matchers](docs/api/jest-matchers) - validate assumptions about your UI
1415
- [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
15-
- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way
16-
purposes
16+
- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way purposes
1717
- Misc APIs:
18-
- [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing
18+
- [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing
1919
- [Async utils](docs/api/misc/async): `findBy*` queries, `wait`, `waitForElementToBeRemoved`
2020
- [Configuration](docs/api/misc/config): `configure`, `resetToDefaults`
2121
- [Accessibility](docs/api/misc/accessibility): `isHiddenFromAccessibility`
+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
["testing-env", "understanding-act"]
1+
["testing-env", "understanding-act", "third-party-integration"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Third-Party Library Integration
2+
3+
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.
4+
5+
## Handling Events in Third-Party Libraries
6+
7+
RNTL provides two subsystems to simulate events:
8+
9+
- **Fire Event**: A lightweight simulation system that can trigger event handlers defined on both host and composite components.
10+
- **User Event**: A more realistic interaction simulation system that can trigger event handlers defined only on host components.
11+
12+
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.
13+
14+
### Example: React Native Gesture Handler
15+
16+
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.
17+
18+
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.
19+
20+
```tsx title="Simplified RNGH Pressable component"
21+
function Pressable({ onPress, onPressIn, onPressOut, onLongPress, ... }) {
22+
23+
// Component logic...
24+
25+
const isTestEnv = process.env.NODE_ENV === 'test';
26+
27+
return (
28+
<GestureDetector gesture={gesture}>
29+
<NativeButton
30+
/* Other props... */
31+
testOnly_onPress={isTestEnv ? onPress : undefined}
32+
testOnly_onPressIn={isTestEnv ? onPressIn : undefined}
33+
testOnly_onPressOut={isTestEnv ? onPressOut : undefined}
34+
testOnly_onLongPress={isTestEnv ? onLongPress : undefined}
35+
/>
36+
</GestureDetector>
37+
);
38+
}
39+
```

website/docs/13.x/docs/api.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
uri: /api
33
---
4+
45
# API Overview
56

67
React Native Testing Library consists of following APIs:
@@ -12,10 +13,9 @@ React Native Testing Library consists of following APIs:
1213
- Helpers: [`debug`](docs/api/screen#debug), [`toJSON`](docs/api/screen#tojson), [`root`](docs/api/screen#root)
1314
- [Jest matchers](docs/api/jest-matchers) - validate assumptions about your UI
1415
- [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
15-
- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way
16-
purposes
16+
- [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way purposes
1717
- Misc APIs:
18-
- [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing
18+
- [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing
1919
- [Async utils](docs/api/misc/async): `findBy*` queries, `wait`, `waitForElementToBeRemoved`
2020
- [Configuration](docs/api/misc/config): `configure`, `resetToDefaults`
2121
- [Accessibility](docs/api/misc/accessibility): `isHiddenFromAccessibility`

0 commit comments

Comments
 (0)