Skip to content

Commit caf03a3

Browse files
coadoj-piaseckim-bert
authored
Add Imperative Gesture Handler Testing API (#4309)
## Description The goal is to make gesture lifecycle tests easier to write when we want to assert application state after each gesture step, without requiring users to manually construct RNGH state events with `state`, `oldState`, or `handlerTag`. Instead of this lower-level style: ```ts fireGestureHandler(panGesture, [ { oldState: State.UNDETERMINED, state: State.BEGAN }, { oldState: State.BEGAN, state: State.ACTIVE }, { oldState: State.ACTIVE, state: State.END }, ]); ``` tests can now use imperative controller: ```ts const gesture = createGestureController(panGesture); gesture.begin(); expect(onBegin).toHaveBeenCalled(); gesture.activate(); expect(onActivate).toHaveBeenCalled(); gesture.update({ translationX: 50 }); expect(onUpdate).toHaveBeenCalledWith( expect.objectContaining({ translationX: 50 }) ); gesture.end(); expect(onFinalize).toHaveBeenCalledWith( expect.objectContaining({ canceled: false }) ); ``` ## Hook gesture rerenders Hook-based gestures can be recreated when their callbacks or configuration change during a React rerender while retaining the same handler tag. The controller now resolves the latest registered gesture before every lifecycle operation. This ensures that subsequent steps use the newest callback closures and configuration, including the current enabled value. For example, if a rerender occurs between `begin()` and `activate()`, `activate()` invokes the callback from the latest render rather than the callback captured when the controller was created. ## Reusing a controller for another stream A controller can now run multiple gesture streams. After `end()`, `fail()`, or `cancel()`, the terminal state remains available for assertions through `getState()`. Calling `begin()` again resets the finished controller internally and starts a new stream. ```ts controller.begin(); controller.activate(); controller.end(); expect(controller.getState()).toBe(State.END); controller.begin(); expect(controller.getState()).toBe(State.BEGAN); ``` ## Test plan Added tests using new API. --------- Co-authored-by: Jakub Piasecki <jakubpiasecki67@gmail.com> Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com>
1 parent 8896a63 commit caf03a3

8 files changed

Lines changed: 731 additions & 17 deletions

File tree

packages/docs-gesture-handler/docs/guides/testing.mdx

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,119 @@ In order to load mocks provided by RNGH, add the following to your jest config:
6262

6363
## Testing Gestures' and Gesture handlers' callbacks
6464

65-
RNGH provides APIs, specifically [`fireGestureHandler`](#firegesturehandler) and [`getByGestureTestId`](#getbygesturetestid), to trigger selected handlers.
65+
RNGH provides the following APIs for triggering selected handlers:
66+
67+
- [`createGestureController`](#creategesturecontroller) to imperatively control a gesture lifecycle one step at a time.
68+
- [`fireGestureHandler`](#firegesturehandler) to dispatch a complete event stream.
69+
- [`getByGestureTestId`](#getbygesturetestid) to find a gesture by its test ID.
70+
71+
### createGestureController
72+
73+
<CollapsibleCode
74+
label="Show composed types definitions"
75+
expandedLabel="Hide composed types definitions"
76+
lineBounds={[0, 3]}
77+
src={`
78+
import { createGestureController } from 'react-native-gesture-handler/jest-utils';
79+
80+
createGestureController: (componentOrGesture) => GestureController;
81+
82+
export interface GestureController<
83+
TEventPayload extends Record<string, unknown> = Record<string, unknown>,
84+
> {
85+
begin: (event?: GestureControllerEvent<TEventPayload>) => void;
86+
activate: (event?: GestureControllerEvent<TEventPayload>) => void;
87+
update: (event?: GestureControllerEvent<TEventPayload>) => void;
88+
end: (event?: GestureControllerEvent<TEventPayload>) => void;
89+
fail: (event?: GestureControllerEvent<TEventPayload>) => void;
90+
cancel: (event?: GestureControllerEvent<TEventPayload>) => void;
91+
}
92+
93+
export type GestureControllerEvent<
94+
TEventPayload extends Record<string, unknown> = Record<string, unknown>,
95+
> = Partial<TEventPayload> & {
96+
handlerTag?: never;
97+
nativeEvent?: never;
98+
oldState?: never;
99+
state?: never;
100+
};
101+
`}/>
102+
103+
Creates an imperative controller that dispatches gesture lifecycle events one step
104+
at a time. This allows the test to assert application state between lifecycle steps
105+
without manually supplying `state`, `oldState`, or `handlerTag`.
106+
107+
`componentOrGesture` can be:
108+
109+
- A Gesture Handler component found using a Jest query such as `getByTestId`.
110+
- A gesture object.
111+
- A gesture test ID string.
112+
113+
When a gesture object is passed directly, the event payload type is inferred from
114+
the gesture. Every lifecycle method accepts an optional partial event payload and
115+
fills omitted handler-specific properties with defaults.
116+
117+
The controller exposes the following methods:
118+
119+
| Method | Behavior |
120+
| ------------ | ---------------------------------------------------------------------------------------------------------------- |
121+
| `begin()` | Starts a stream and calls `onBegin`. If the previous stream finished, the controller resets it before beginning. |
122+
| `activate()` | Activates a begun stream and calls `onActivate`. |
123+
| `update()` | Dispatches an update for an active stream and calls `onUpdate`. It can be called multiple times. |
124+
| `end()` | Ends a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: false`. |
125+
| `fail()` | Fails a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. |
126+
| `cancel()` | Cancels a begun or active stream. Calls `onDeactivate` if active, then `onFinalize` with `canceled: true`. |
127+
128+
Calling `begin()` again after `end()`, `fail()`, or `cancel()` starts another stream with the same controller.
129+
Calling methods in an invalid order throws an error. State-machine fields such as
130+
`state`, `oldState`, `handlerTag`, and `nativeEvent` cannot be supplied in event
131+
payloads because the controller manages them internally.
132+
133+
```tsx
134+
test('updates application state after each gesture step', () => {
135+
const onBegin = jest.fn();
136+
const onActivate = jest.fn();
137+
const onUpdate = jest.fn();
138+
const onDeactivate = jest.fn();
139+
const onFinalize = jest.fn();
140+
141+
const panGesture = renderHook(() =>
142+
usePanGesture({
143+
disableReanimated: true,
144+
onBegin,
145+
onActivate,
146+
onUpdate,
147+
onDeactivate,
148+
onFinalize,
149+
})
150+
).result.current;
151+
152+
const controller = createGestureController(panGesture);
153+
154+
controller.begin();
155+
expect(onBegin).toHaveBeenCalledTimes(1);
156+
157+
controller.activate();
158+
expect(onActivate).toHaveBeenCalledTimes(1);
159+
160+
controller.update({ translationX: 50 });
161+
expect(onUpdate).toHaveBeenCalledWith(
162+
expect.objectContaining({ translationX: 50 })
163+
);
164+
165+
controller.end();
166+
expect(onDeactivate).toHaveBeenCalledTimes(1);
167+
expect(onFinalize).toHaveBeenCalledWith(
168+
expect.objectContaining({ canceled: false })
169+
);
170+
});
171+
```
172+
173+
:::note
174+
`createGestureController` controls lifecycle events directly. It does not generate
175+
pointer input, run platform gesture recognizers, or evaluate relations between
176+
gestures.
177+
:::
66178

67179
### fireGestureHandler
68180

@@ -133,7 +245,7 @@ components.
133245
`testID` must be unique among components rendered in test.
134246
:::
135247

136-
## Example
248+
## fireGestureHandler example
137249

138250
Extracted from RNGH tests, check [`api_v3.test.tsx`](https://github.com/software-mansion/react-native-gesture-handler/blob/main/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx) for full implementation.
139251

packages/react-native-gesture-handler/src/__tests__/Errors.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ describe('VirtualDetector', () => {
6565

6666
function InterceptingDetectorMultipleTypes() {
6767
const tap = useTapGesture({ useAnimated: true });
68-
const tap2 = useTapGesture({ onActivate: mockWorklet });
68+
const tap2 = useTapGesture({
69+
disableReanimated: false,
70+
onActivate: mockWorklet,
71+
});
6972
return (
7073
<GestureHandlerRootView>
7174
<InterceptingGestureDetector gesture={tap}>

0 commit comments

Comments
 (0)