Skip to content

Commit

Permalink
feat: testOnly events (#1741)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdjastrzebski authored Feb 5, 2025
1 parent 315afca commit c47220b
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 39 deletions.
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}`];
}

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);
}
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

0 comments on commit c47220b

Please sign in to comment.