Skip to content

feat: testOnly events #1741

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 5, 2025
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
59 changes: 59 additions & 0 deletions src/__tests__/event-handler.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<View>
<Text testID="regular" onPress={onPress} />
{/* @ts-expect-error Intentionally passing such props */}
<View testID="testOnly" testOnly_onPress={testOnlyOnPress} />
{/* @ts-expect-error Intentionally passing such props */}
<View testID="both" onPress={onPress} testOnly_onPress={testOnlyOnPress} />
</View>,
);

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(
<View>
<Text testID="regular" onPress={onPress} />
{/* @ts-expect-error Intentionally passing such props */}
<View testID="testOnly" testOnly_onPress={testOnlyOnPress} />
{/* @ts-expect-error Intentionally passing such props */}
<View testID="both" onPress={onPress} testOnly_onPress={testOnlyOnPress} />
</View>,
);

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);
});
39 changes: 39 additions & 0 deletions src/event-handler.ts
Original file line number Diff line number Diff line change
@@ -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}`];

Check warning on line 23 in src/event-handler.ts

View check run for this annotation

Codecov / codecov/patch

src/event-handler.ts#L23

Added line #L23 was not covered by tests
}

if (options?.loose && typeof element.props[`testOnly_${eventName}`] === 'function') {
return element.props[`testOnly_${eventName}`];

Check warning on line 27 in src/event-handler.ts

View check run for this annotation

Codecov / codecov/patch

src/event-handler.ts#L27

Added line #L27 was not covered by tests
}

return undefined;
}

export function getEventHandlerName(eventName: string) {
return `on${capitalizeFirstLetter(eventName)}`;
}

function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
20 changes: 2 additions & 18 deletions src/fire-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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<T> = keyof {
[K in keyof T as K extends `on${infer Rest}` ? Uncapitalize<Rest> : never]: T[K];
Expand Down
15 changes: 1 addition & 14 deletions src/user-event/utils/dispatch-event.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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)}`;
}
6 changes: 3 additions & 3 deletions website/docs/12.x/docs/api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
uri: /api
---

# API Overview

React Native Testing Library consists of following APIs:
Expand All @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion website/docs/13.x/docs/advanced/_meta.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["testing-env", "understanding-act"]
["testing-env", "understanding-act", "third-party-integration"]
39 changes: 39 additions & 0 deletions website/docs/13.x/docs/advanced/third-party-integration.mdx
Original file line number Diff line number Diff line change
@@ -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 (
<GestureDetector gesture={gesture}>
<NativeButton
/* Other props... */
testOnly_onPress={isTestEnv ? onPress : undefined}
testOnly_onPressIn={isTestEnv ? onPressIn : undefined}
testOnly_onPressOut={isTestEnv ? onPressOut : undefined}
testOnly_onLongPress={isTestEnv ? onLongPress : undefined}
/>
</GestureDetector>
);
}
```
6 changes: 3 additions & 3 deletions website/docs/13.x/docs/api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
uri: /api
---

# API Overview

React Native Testing Library consists of following APIs:
Expand All @@ -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`
Expand Down