@nyby/detox-component-testing 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # @nyby/detox-component-testing
2
+
3
+ Component testing for React Native with [Detox](https://github.com/wix/Detox). Mount individual components in isolation on a real device or emulator and test them with the full Detox API.
4
+
5
+ ## How It Works
6
+
7
+ Unlike Cypress (where tests and components share the same browser runtime), Detox tests run in **Node.js** while components render on a **device/emulator**. The two communicate over WebSocket.
8
+
9
+ ```
10
+ Test (Node.js/Jest) App (React Native on device)
11
+ ─────────────────── ────────────────────────────
12
+ mount('Stepper', { initial: 5 }) → renders <Stepper initial={5} />
13
+ element(by.id('increment')).tap() → taps the native button
14
+ expect(...).toHaveText('6') → asserts on the native view tree
15
+ ```
16
+
17
+ This means test files **cannot** import React components or use JSX — they can only send serializable data (strings, numbers, booleans) to the device. The `mount()` function references components by the name they were registered with.
18
+
19
+ ## Setup
20
+
21
+ ### 1. Install dependencies
22
+
23
+ ```bash
24
+ npm install @nyby/detox-component-testing react-native-launch-arguments
25
+ ```
26
+
27
+ `react-native-launch-arguments` is a native module — rebuild your app after installing.
28
+
29
+ ### 2. Create a component test entry point
30
+
31
+ Create `app.component-test.js` alongside your app entry:
32
+
33
+ ```js
34
+ import { AppRegistry } from 'react-native';
35
+ import { ComponentHarness } from '@nyby/detox-component-testing';
36
+ import './component-tests/registry';
37
+
38
+ AppRegistry.registerComponent('example', () => ComponentHarness);
39
+ ```
40
+
41
+ ### 3. Register your components
42
+
43
+ Create `component-tests/registry.ts` to register components for testing:
44
+
45
+ ```ts
46
+ import { registerComponent } from '@nyby/detox-component-testing';
47
+ import { Stepper } from '../src/components/Stepper';
48
+ import { LoginForm } from '../src/components/LoginForm';
49
+
50
+ // Auto-infer name from Component.name
51
+ registerComponent(Stepper, { initial: 0 });
52
+ registerComponent(LoginForm);
53
+
54
+ // Or use an explicit name
55
+ registerComponent('MyComponent', MyComponent, { someProp: 'default' });
56
+ ```
57
+
58
+ ### 4. Switch entry point for component tests
59
+
60
+ Update your index file to load the component test entry when running component tests:
61
+
62
+ ```js
63
+ // index.js
64
+ const { LaunchArguments } = require('react-native-launch-arguments');
65
+
66
+ if (LaunchArguments.value().detoxComponentName) {
67
+ require('./app.component-test');
68
+ } else {
69
+ require('./app');
70
+ }
71
+ ```
72
+
73
+ This keeps your production app clean — the harness and test components are only loaded during component testing.
74
+
75
+ ### 5. Configure Detox
76
+
77
+ Add a component test configuration to `detox.config.js`:
78
+
79
+ ```js
80
+ module.exports = {
81
+ // ... existing config
82
+ configurations: {
83
+ // ... existing configurations
84
+ "android.emu.component.debug": {
85
+ device: "emulator",
86
+ app: "android.debug",
87
+ testRunner: {
88
+ args: {
89
+ config: "component-tests/jest.config.js",
90
+ _: ["src/components"]
91
+ }
92
+ }
93
+ }
94
+ }
95
+ };
96
+ ```
97
+
98
+ ### 6. Create Jest config for component tests
99
+
100
+ Create `component-tests/jest.config.js`:
101
+
102
+ ```js
103
+ module.exports = {
104
+ maxWorkers: 1,
105
+ globalSetup: "detox/runners/jest/globalSetup",
106
+ globalTeardown: "detox/runners/jest/globalTeardown",
107
+ testEnvironment: "detox/runners/jest/testEnvironment",
108
+ setupFilesAfterEnv: ["./setup.ts"],
109
+ testRunner: "jest-circus/runner",
110
+ testTimeout: 120000,
111
+ roots: ["<rootDir>/../src"],
112
+ testMatch: ["**/*.component.test.ts"],
113
+ transform: {
114
+ "\\.tsx?$": ["ts-jest", { tsconfig: "<rootDir>/../tsconfig.json" }]
115
+ },
116
+ reporters: ["detox/runners/jest/reporter"],
117
+ verbose: true
118
+ };
119
+ ```
120
+
121
+ ## Writing Tests
122
+
123
+ ### Basic mounting
124
+
125
+ ```ts
126
+ import { by, element, expect } from 'detox';
127
+ import { mount } from '@nyby/detox-component-testing/test';
128
+
129
+ describe('Stepper', () => {
130
+ it('renders with default props', async () => {
131
+ await mount('Stepper');
132
+ await expect(element(by.id('counter'))).toHaveText('0');
133
+ });
134
+
135
+ it('renders with custom props', async () => {
136
+ await mount('Stepper', { initial: 100 });
137
+ await expect(element(by.id('counter'))).toHaveText('100');
138
+ });
139
+ });
140
+ ```
141
+
142
+ ### Testing callbacks with spies
143
+
144
+ Use `spy()` to create a recording function for callback props, and `expectSpy()` to assert on it:
145
+
146
+ ```ts
147
+ import { mount, spy, expectSpy } from '@nyby/detox-component-testing/test';
148
+
149
+ it('fires onChange when incremented', async () => {
150
+ await mount('Stepper', { initial: 0, onChange: spy('onChange') });
151
+ await element(by.id('increment')).tap();
152
+
153
+ await expectSpy('onChange').toHaveBeenCalled();
154
+ await expectSpy('onChange').toHaveBeenCalledTimes(1);
155
+ await expectSpy('onChange').lastCalledWith(1);
156
+ });
157
+ ```
158
+
159
+ The spy system works across the process boundary — the app-side harness creates a real recording function and exposes call data via hidden UI elements that `expectSpy()` reads.
160
+
161
+ ### Performance
162
+
163
+ The first `mount()` call per test file launches the app (~9s). Subsequent mounts within the same file swap the component in-place without restarting — typically under 100ms.
164
+
165
+ ```
166
+ First mount: ~9s (app launch)
167
+ Subsequent mounts: ~100ms (in-place swap)
168
+ ```
169
+
170
+ ## API Reference
171
+
172
+ ### App-side (import from `@nyby/detox-component-testing`)
173
+
174
+ #### `registerComponent(Component, defaultProps?)`
175
+ #### `registerComponent(name, Component, defaultProps?)`
176
+
177
+ Register a component for testing. When called with just a component, the name is inferred from `Component.name` or `Component.displayName`.
178
+
179
+ #### `ComponentHarness`
180
+
181
+ Root component for the test harness. Register as your app's root component in the component test entry point.
182
+
183
+ #### `configureHarness({ wrapper })`
184
+
185
+ Set a global wrapper component for all mounted components (e.g. Redux Provider, theme provider):
186
+
187
+ ```js
188
+ import { Provider } from 'react-redux';
189
+ import { configureHarness } from '@nyby/detox-component-testing';
190
+ import { createStore } from './store';
191
+
192
+ configureHarness({
193
+ wrapper: ({ children, launchArgs }) => {
194
+ const store = createStore();
195
+ if (launchArgs.reduxState) {
196
+ store.dispatch(loadState(JSON.parse(launchArgs.reduxState)));
197
+ }
198
+ return <Provider store={store}>{children}</Provider>;
199
+ },
200
+ });
201
+ ```
202
+
203
+ The wrapper receives `launchArgs` — the props passed to `mount()` — so you can configure per-test state.
204
+
205
+ ### Test-side (import from `@nyby/detox-component-testing/test`)
206
+
207
+ #### `mount(componentName, props?)`
208
+
209
+ Mount a registered component on the device. Props are passed as flat key-value pairs (strings, numbers, booleans). Use `spy()` for callback props.
210
+
211
+ #### `spy(name)`
212
+
213
+ Create a spy marker for a callback prop. The harness replaces this with a recording function on the app side.
214
+
215
+ #### `expectSpy(name)`
216
+
217
+ Returns an assertion object for a spy:
218
+
219
+ - `.toHaveBeenCalled()` — spy was called at least once
220
+ - `.toHaveBeenCalledTimes(n)` — spy was called exactly `n` times
221
+ - `.lastCalledWith(...args)` — the last call's arguments match
222
+
223
+ ## Limitations
224
+
225
+ - **No JSX in tests** — Tests run in Node.js, not the React Native runtime. You cannot import components or use JSX in test files. Reference components by their registered name string.
226
+ - **Flat props only** — Props passed through `mount()` must be serializable (strings, numbers, booleans). Nested objects and arrays are not supported. Use `configureHarness({ wrapper })` for complex state like Redux stores.
227
+ - **Callbacks are spies, not real functions** — You can't pass arbitrary callback implementations. Use `spy()` to record calls and assert on them with `expectSpy()`.
228
+
229
+ ## Project Structure
230
+
231
+ A typical project using `@nyby/detox-component-testing`:
232
+
233
+ ```
234
+ src/
235
+ components/
236
+ Stepper.tsx # Component source
237
+ stepper.component.test.ts # Component test (co-located)
238
+ LoginForm.tsx
239
+ loginForm.component.test.ts
240
+
241
+ component-tests/
242
+ registry.ts # Component registrations
243
+ jest.config.js # Jest config for component tests
244
+ setup.ts # Test setup
245
+
246
+ app.js # Production app entry
247
+ app.component-test.js # Component test entry (harness)
248
+ index.js # Switches entry based on launch args
249
+ detox.config.js # Detox config with component test configuration
250
+ ```
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@nyby/detox-component-testing",
3
+ "version": "1.0.0",
4
+ "description": "Component testing support for Detox and React Native",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./test": "./src/test.ts"
9
+ },
10
+ "peerDependencies": {
11
+ "react": "*",
12
+ "react-native": "*",
13
+ "react-native-launch-arguments": "*",
14
+ "detox": "*"
15
+ }
16
+ }
@@ -0,0 +1,117 @@
1
+ import React, { useState, useRef, useCallback } from 'react';
2
+ import { View, Text, TextInput } from 'react-native';
3
+ import { LaunchArguments } from 'react-native-launch-arguments';
4
+ import { getComponent } from './ComponentRegistry';
5
+ import { getWrapper } from './configureHarness';
6
+
7
+ const PROP_PREFIX = 'detoxProp_';
8
+ const SPY_PREFIX = 'detoxSpy_';
9
+
10
+ interface MountPayload {
11
+ id: string;
12
+ name: string;
13
+ props: Record<string, any>;
14
+ spies: string[];
15
+ }
16
+
17
+ function parseLaunchArgs(args: Record<string, any>): { props: Record<string, any>; spies: string[] } {
18
+ const props: Record<string, any> = {};
19
+ const spies: string[] = [];
20
+ Object.entries(args).forEach(([key, value]) => {
21
+ if (key.startsWith(PROP_PREFIX)) {
22
+ props[key.slice(PROP_PREFIX.length)] = value;
23
+ } else if (key.startsWith(SPY_PREFIX)) {
24
+ spies.push(key.slice(SPY_PREFIX.length));
25
+ }
26
+ });
27
+ return { props, spies };
28
+ }
29
+
30
+ export function ComponentHarness() {
31
+ const launchArgs = LaunchArguments.value() as Record<string, any>;
32
+ const [mountPayload, setMountPayload] = useState<MountPayload | null>(null);
33
+
34
+ const handleControl = useCallback((text: string) => {
35
+ try {
36
+ setMountPayload(JSON.parse(text));
37
+ } catch (_e) {}
38
+ }, []);
39
+
40
+ let activeMount: MountPayload | null = null;
41
+ if (mountPayload) {
42
+ activeMount = mountPayload;
43
+ } else if (launchArgs.detoxComponentName) {
44
+ const { props, spies } = parseLaunchArgs(launchArgs);
45
+ activeMount = {
46
+ id: '0',
47
+ name: launchArgs.detoxComponentName as string,
48
+ props,
49
+ spies,
50
+ };
51
+ }
52
+
53
+ return (
54
+ <View style={{ flex: 1 }}>
55
+ <TextInput
56
+ testID="detox-harness-control"
57
+ onChangeText={handleControl}
58
+ style={{ height: 1 }}
59
+ />
60
+ {activeMount && (
61
+ <ComponentRenderer key={activeMount.id} mount={activeMount} />
62
+ )}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ interface SpyData {
68
+ count: number;
69
+ lastArgs: any[];
70
+ }
71
+
72
+ function ComponentRenderer({ mount }: { mount: MountPayload }) {
73
+ const { Component, defaultProps } = getComponent(mount.name);
74
+ const spyNames = mount.spies || [];
75
+
76
+ const initialData: Record<string, SpyData> = {};
77
+ spyNames.forEach(name => {
78
+ initialData[name] = { count: 0, lastArgs: [] };
79
+ });
80
+
81
+ const [spyData, setSpyData] = useState(initialData);
82
+
83
+ const spyFnsRef = useRef<Record<string, (...args: any[]) => void>>({});
84
+ const spyProps: Record<string, (...args: any[]) => void> = {};
85
+ spyNames.forEach(name => {
86
+ if (!spyFnsRef.current[name]) {
87
+ spyFnsRef.current[name] = (...callArgs: any[]) => {
88
+ setSpyData(prev => ({
89
+ ...prev,
90
+ [name]: {
91
+ count: (prev[name]?.count || 0) + 1,
92
+ lastArgs: callArgs,
93
+ },
94
+ }));
95
+ };
96
+ }
97
+ spyProps[name] = spyFnsRef.current[name];
98
+ });
99
+
100
+ const props = { ...defaultProps, ...(mount.props || {}), ...spyProps };
101
+ const Wrapper = getWrapper();
102
+
103
+ return (
104
+ <Wrapper launchArgs={mount.props || {}}>
105
+ <View testID="component-harness-root" style={{ flex: 1 }}>
106
+ <Text testID="detox-mount-id" style={{ height: 1 }}>{mount.id}</Text>
107
+ <Component {...props} />
108
+ {spyNames.map(name => (
109
+ <View key={name}>
110
+ <Text testID={`spy-${name}-count`}>{String(spyData[name].count)}</Text>
111
+ <Text testID={`spy-${name}-lastArgs`}>{JSON.stringify(spyData[name].lastArgs)}</Text>
112
+ </View>
113
+ ))}
114
+ </View>
115
+ </Wrapper>
116
+ );
117
+ }
@@ -0,0 +1,56 @@
1
+ import { ComponentType } from 'react';
2
+
3
+ export interface ComponentEntry<P = any> {
4
+ Component: ComponentType<P>;
5
+ defaultProps: Partial<P>;
6
+ }
7
+
8
+ const registry = new Map<string, ComponentEntry>();
9
+
10
+ export function registerComponent<P>(
11
+ name: string,
12
+ Component: ComponentType<P>,
13
+ defaultProps?: Partial<P>,
14
+ ): void;
15
+ export function registerComponent<P>(
16
+ Component: ComponentType<P>,
17
+ defaultProps?: Partial<P>,
18
+ ): void;
19
+ export function registerComponent<P>(
20
+ nameOrComponent: string | ComponentType<P>,
21
+ componentOrProps?: ComponentType<P> | Partial<P>,
22
+ defaultProps?: Partial<P>,
23
+ ): void {
24
+ if (typeof nameOrComponent === 'string') {
25
+ registry.set(nameOrComponent, {
26
+ Component: componentOrProps as ComponentType<P>,
27
+ defaultProps: (defaultProps || {}) as Partial<P>,
28
+ });
29
+ } else {
30
+ const Component = nameOrComponent;
31
+ const name = Component.displayName || Component.name;
32
+ if (!name) {
33
+ throw new Error(
34
+ '[detox-component-testing] Component must have a name or displayName to register without an explicit name.',
35
+ );
36
+ }
37
+ registry.set(name, {
38
+ Component,
39
+ defaultProps: ((componentOrProps as Partial<P>) || {}) as Partial<P>,
40
+ });
41
+ }
42
+ }
43
+
44
+ export function getComponent(name: string): ComponentEntry {
45
+ const entry = registry.get(name);
46
+ if (!entry) {
47
+ throw new Error(
48
+ `[detox-component-testing] Component "${name}" not found in registry. Did you call registerComponent()?`,
49
+ );
50
+ }
51
+ return entry;
52
+ }
53
+
54
+ export function getAll(): Record<string, ComponentEntry> {
55
+ return Object.fromEntries(registry);
56
+ }
@@ -0,0 +1,22 @@
1
+ import { ComponentType, ReactNode } from 'react';
2
+
3
+ export interface WrapperProps {
4
+ children: ReactNode;
5
+ launchArgs: Record<string, any>;
6
+ }
7
+
8
+ export interface HarnessConfig {
9
+ wrapper: ComponentType<WrapperProps>;
10
+ }
11
+
12
+ const DefaultWrapper = ({ children }: WrapperProps) => children;
13
+
14
+ let globalWrapper: ComponentType<WrapperProps> | null = null;
15
+
16
+ export function configureHarness(config: HarnessConfig): void {
17
+ globalWrapper = config.wrapper;
18
+ }
19
+
20
+ export function getWrapper(): ComponentType<WrapperProps> {
21
+ return globalWrapper || DefaultWrapper;
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { registerComponent, getComponent, getAll, ComponentEntry } from './ComponentRegistry';
2
+ export { ComponentHarness } from './ComponentHarness';
3
+ export { configureHarness, WrapperProps, HarnessConfig } from './configureHarness';
package/src/mount.ts ADDED
@@ -0,0 +1,75 @@
1
+ export interface SpyMarker {
2
+ readonly __detoxSpy__: true;
3
+ readonly name: string;
4
+ }
5
+
6
+ export interface SpyExpectation {
7
+ toHaveBeenCalled(): Promise<void>;
8
+ toHaveBeenCalledTimes(n: number): Promise<void>;
9
+ lastCalledWith(...args: any[]): Promise<void>;
10
+ }
11
+
12
+ const SPY_MARKER = '__detoxSpy__' as const;
13
+
14
+ let mountCounter = 0;
15
+ let appLaunched = false;
16
+
17
+ export function spy(name: string): SpyMarker {
18
+ return { [SPY_MARKER]: true, name };
19
+ }
20
+
21
+ type MountProps = Record<string, string | number | boolean | SpyMarker>;
22
+
23
+ export async function mount(componentName: string, props?: MountProps): Promise<void> {
24
+
25
+ const payload = {
26
+ id: String(++mountCounter),
27
+ name: componentName,
28
+ props: {} as Record<string, any>,
29
+ spies: [] as string[],
30
+ };
31
+
32
+ if (props) {
33
+ Object.entries(props).forEach(([key, value]) => {
34
+ if (value && typeof value === 'object' && SPY_MARKER in value) {
35
+ payload.spies.push(key);
36
+ } else {
37
+ payload.props[key] = value;
38
+ }
39
+ });
40
+ }
41
+
42
+ if (!appLaunched) {
43
+ const launchArgs: Record<string, any> = { detoxComponentName: componentName };
44
+ Object.entries(payload.props).forEach(([key, value]) => {
45
+ launchArgs[`detoxProp_${key}`] = value;
46
+ });
47
+ payload.spies.forEach(name => {
48
+ launchArgs[`detoxSpy_${name}`] = true;
49
+ });
50
+ await device.launchApp({ newInstance: true, launchArgs });
51
+ appLaunched = true;
52
+ return;
53
+ }
54
+
55
+ await element(by.id('detox-harness-control')).replaceText(JSON.stringify(payload));
56
+ await waitFor(element(by.id('detox-mount-id'))).toHaveText(payload.id).withTimeout(5000);
57
+ }
58
+
59
+ // Detox's expect is injected as a global by the test environment.
60
+ // Cast to any since the library can't import 'detox' directly (peer dep).
61
+ const detoxExpect = expect as unknown as (e: any) => any;
62
+
63
+ export function expectSpy(name: string): SpyExpectation {
64
+ return {
65
+ async toHaveBeenCalled() {
66
+ await detoxExpect(element(by.id(`spy-${name}-count`))).not.toHaveText('0');
67
+ },
68
+ async toHaveBeenCalledTimes(n: number) {
69
+ await detoxExpect(element(by.id(`spy-${name}-count`))).toHaveText(String(n));
70
+ },
71
+ async lastCalledWith(...args: any[]) {
72
+ await detoxExpect(element(by.id(`spy-${name}-lastArgs`))).toHaveText(JSON.stringify(args));
73
+ },
74
+ };
75
+ }
package/src/test.ts ADDED
@@ -0,0 +1 @@
1
+ export { mount, spy, expectSpy, SpyMarker, SpyExpectation } from './mount';
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2018",
4
+ "module": "commonjs",
5
+ "jsx": "react",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "dist",
10
+ "strict": true,
11
+ "moduleResolution": "node",
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": ["src"]
17
+ }