or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

Files

docs

async-testing.mdconfiguration.mdhooks.mdindex.mdinteractions.mdmatchers.mdqueries.mdrendering.md

hooks.mddocs/

0

# Hook Testing

1

2

Specialized utilities for testing React hooks in isolation with proper act wrapping, lifecycle management, and both synchronous and asynchronous rendering support.

3

4

## Capabilities

5

6

### RenderHook Function

7

8

Test React hooks in isolation without needing to create wrapper components.

9

10

```typescript { .api }

11

/**

12

* Render a hook for testing in isolation

13

* @param hook - Hook function to test

14

* @param options - Hook rendering options

15

* @returns RenderHookResult with hook result and utilities

16

*/

17

function renderHook<Result, Props>(

18

hook: (props: Props) => Result,

19

options?: RenderHookOptions<Props>

20

): RenderHookResult<Result, Props>;

21

22

interface RenderHookOptions<Props> {

23

/** Initial props to pass to the hook */

24

initialProps?: Props;

25

26

/** React component wrapper (for providers) */

27

wrapper?: React.ComponentType<any>;

28

29

/** Enable/disable concurrent rendering */

30

concurrentRoot?: boolean;

31

}

32

33

interface RenderHookResult<Result, Props> {

34

/** Current hook result in a ref object */

35

result: { current: Result };

36

37

/** Re-render hook with new props */

38

rerender: (props?: Props) => void;

39

40

/** Unmount the hook */

41

unmount: () => void;

42

}

43

```

44

45

**Usage Examples:**

46

47

```typescript

48

import { renderHook } from "@testing-library/react-native";

49

50

test("testing useState hook", () => {

51

const useCounter = (initialValue = 0) => {

52

const [count, setCount] = useState(initialValue);

53

54

const increment = () => setCount(prev => prev + 1);

55

const decrement = () => setCount(prev => prev - 1);

56

const reset = () => setCount(initialValue);

57

58

return { count, increment, decrement, reset };

59

};

60

61

const { result } = renderHook(useCounter, {

62

initialProps: 5

63

});

64

65

// Initial state

66

expect(result.current.count).toBe(5);

67

68

// Test increment

69

act(() => {

70

result.current.increment();

71

});

72

expect(result.current.count).toBe(6);

73

74

// Test decrement

75

act(() => {

76

result.current.decrement();

77

});

78

expect(result.current.count).toBe(5);

79

80

// Test reset

81

act(() => {

82

result.current.reset();

83

});

84

expect(result.current.count).toBe(5);

85

});

86

87

test("testing useEffect hook", () => {

88

const useDocumentTitle = (title) => {

89

const [currentTitle, setCurrentTitle] = useState(title);

90

91

useEffect(() => {

92

setCurrentTitle(title);

93

}, [title]);

94

95

return currentTitle;

96

};

97

98

const { result, rerender } = renderHook(useDocumentTitle, {

99

initialProps: "Initial Title"

100

});

101

102

// Initial title

103

expect(result.current).toBe("Initial Title");

104

105

// Update title

106

rerender("Updated Title");

107

expect(result.current).toBe("Updated Title");

108

});

109

```

110

111

### Async Hook Testing

112

113

Test hooks with asynchronous behavior using renderHookAsync and proper async handling.

114

115

```typescript { .api }

116

/**

117

* Async version of renderHook for testing hooks with async behavior

118

* @param hook - Hook function to test

119

* @param options - Async hook rendering options

120

* @returns Promise resolving to RenderHookAsyncResult

121

*/

122

function renderHookAsync<Result, Props>(

123

hook: (props: Props) => Result,

124

options?: RenderHookOptions<Props>

125

): Promise<RenderHookAsyncResult<Result, Props>>;

126

127

interface RenderHookAsyncResult<Result, Props> {

128

/** Current hook result in a ref object */

129

result: { current: Result };

130

131

/** Re-render hook with new props asynchronously */

132

rerenderAsync: (props?: Props) => Promise<void>;

133

134

/** Unmount the hook asynchronously */

135

unmountAsync: () => Promise<void>;

136

}

137

```

138

139

**Usage Examples:**

140

141

```typescript

142

test("async hook testing", async () => {

143

const useAsyncData = (url) => {

144

const [data, setData] = useState(null);

145

const [loading, setLoading] = useState(true);

146

const [error, setError] = useState(null);

147

148

useEffect(() => {

149

const fetchData = async () => {

150

try {

151

setLoading(true);

152

setError(null);

153

154

// Simulate API call

155

await new Promise(resolve => setTimeout(resolve, 100));

156

157

if (url === "/error") {

158

throw new Error("API Error");

159

}

160

161

setData(`Data from ${url}`);

162

} catch (err) {

163

setError(err.message);

164

} finally {

165

setLoading(false);

166

}

167

};

168

169

fetchData();

170

}, [url]);

171

172

return { data, loading, error };

173

};

174

175

const { result } = await renderHookAsync(useAsyncData, {

176

initialProps: "/api/users"

177

});

178

179

// Initially loading

180

expect(result.current.loading).toBe(true);

181

expect(result.current.data).toBeNull();

182

expect(result.current.error).toBeNull();

183

184

// Wait for data to load

185

await waitFor(() => {

186

expect(result.current.loading).toBe(false);

187

});

188

189

expect(result.current.data).toBe("Data from /api/users");

190

expect(result.current.error).toBeNull();

191

});

192

193

test("async hook error handling", async () => {

194

const useAsyncData = (url) => {

195

const [data, setData] = useState(null);

196

const [loading, setLoading] = useState(true);

197

const [error, setError] = useState(null);

198

199

useEffect(() => {

200

const fetchData = async () => {

201

try {

202

setLoading(true);

203

setError(null);

204

await new Promise(resolve => setTimeout(resolve, 50));

205

206

if (url === "/error") {

207

throw new Error("Network error");

208

}

209

210

setData(`Success: ${url}`);

211

} catch (err) {

212

setError(err.message);

213

} finally {

214

setLoading(false);

215

}

216

};

217

218

fetchData();

219

}, [url]);

220

221

return { data, loading, error };

222

};

223

224

const { result, rerenderAsync } = await renderHookAsync(useAsyncData, {

225

initialProps: "/api/success"

226

});

227

228

// Wait for successful load

229

await waitFor(() => {

230

expect(result.current.loading).toBe(false);

231

});

232

233

expect(result.current.data).toBe("Success: /api/success");

234

expect(result.current.error).toBeNull();

235

236

// Test error case

237

await rerenderAsync("/error");

238

239

await waitFor(() => {

240

expect(result.current.loading).toBe(false);

241

});

242

243

expect(result.current.data).toBeNull();

244

expect(result.current.error).toBe("Network error");

245

});

246

```

247

248

### Hook Testing with Context

249

250

Test hooks that depend on React context by providing wrapper components.

251

252

```typescript { .api }

253

/**

254

* Wrapper component type for providing context to hooks

255

*/

256

type WrapperComponent<Props = {}> = React.ComponentType<Props & { children: React.ReactNode }>;

257

```

258

259

**Usage Examples:**

260

261

```typescript

262

// Context setup

263

const ThemeContext = React.createContext({

264

theme: "light",

265

toggleTheme: () => {}

266

});

267

268

const ThemeProvider = ({ children, initialTheme = "light" }) => {

269

const [theme, setTheme] = useState(initialTheme);

270

271

const toggleTheme = () => {

272

setTheme(prev => prev === "light" ? "dark" : "light");

273

};

274

275

return (

276

<ThemeContext.Provider value={{ theme, toggleTheme }}>

277

{children}

278

</ThemeContext.Provider>

279

);

280

};

281

282

// Hook that uses context

283

const useTheme = () => {

284

const context = useContext(ThemeContext);

285

if (!context) {

286

throw new Error("useTheme must be used within ThemeProvider");

287

}

288

return context;

289

};

290

291

test("hook with context provider", () => {

292

const { result } = renderHook(useTheme, {

293

wrapper: ({ children }) => (

294

<ThemeProvider initialTheme="dark">

295

{children}

296

</ThemeProvider>

297

)

298

});

299

300

// Initial theme from provider

301

expect(result.current.theme).toBe("dark");

302

303

// Test theme toggle

304

act(() => {

305

result.current.toggleTheme();

306

});

307

308

expect(result.current.theme).toBe("light");

309

310

// Toggle again

311

act(() => {

312

result.current.toggleTheme();

313

});

314

315

expect(result.current.theme).toBe("dark");

316

});

317

318

test("hook without context throws error", () => {

319

expect(() => {

320

renderHook(useTheme); // No wrapper provided

321

}).toThrow("useTheme must be used within ThemeProvider");

322

});

323

324

test("hook with multiple providers", () => {

325

const UserContext = React.createContext({ user: null });

326

const UserProvider = ({ children, user }) => (

327

<UserContext.Provider value={{ user }}>

328

{children}

329

</UserContext.Provider>

330

);

331

332

const useUserTheme = () => {

333

const { theme } = useTheme();

334

const { user } = useContext(UserContext);

335

336

return {

337

theme,

338

userTheme: user?.preferredTheme || theme,

339

user

340

};

341

};

342

343

const MultiProvider = ({ children }) => (

344

<ThemeProvider initialTheme="light">

345

<UserProvider user={{ name: "John", preferredTheme: "dark" }}>

346

{children}

347

</UserProvider>

348

</ThemeProvider>

349

);

350

351

const { result } = renderHook(useUserTheme, {

352

wrapper: MultiProvider

353

});

354

355

expect(result.current.theme).toBe("light");

356

expect(result.current.userTheme).toBe("dark");

357

expect(result.current.user.name).toBe("John");

358

});

359

```

360

361

### Custom Hook Testing Patterns

362

363

Advanced patterns for testing complex custom hooks.

364

365

```typescript { .api }

366

/**

367

* Test utilities for complex hook scenarios

368

*/

369

```

370

371

**Usage Examples:**

372

373

```typescript

374

test("custom hook with dependencies", () => {

375

const useDebounce = (value, delay) => {

376

const [debouncedValue, setDebouncedValue] = useState(value);

377

378

useEffect(() => {

379

const handler = setTimeout(() => {

380

setDebouncedValue(value);

381

}, delay);

382

383

return () => {

384

clearTimeout(handler);

385

};

386

}, [value, delay]);

387

388

return debouncedValue;

389

};

390

391

const { result, rerender } = renderHook(useDebounce, {

392

initialProps: { value: "initial", delay: 500 }

393

});

394

395

// Initial value is set immediately

396

expect(result.current).toBe("initial");

397

398

// Change value

399

rerender({ value: "updated", delay: 500 });

400

401

// Value should still be "initial" until debounce delay

402

expect(result.current).toBe("initial");

403

404

// Fast-forward time

405

act(() => {

406

jest.advanceTimersByTime(500);

407

});

408

409

// Now value should be updated

410

expect(result.current).toBe("updated");

411

412

// Change delay and value again

413

rerender({ value: "final", delay: 200 });

414

expect(result.current).toBe("updated"); // Still old value

415

416

act(() => {

417

jest.advanceTimersByTime(200);

418

});

419

420

expect(result.current).toBe("final");

421

});

422

423

test("hook with cleanup", () => {

424

const useEventListener = (eventName, handler) => {

425

const savedHandler = useRef(handler);

426

427

useEffect(() => {

428

savedHandler.current = handler;

429

}, [handler]);

430

431

useEffect(() => {

432

const eventListener = (event) => savedHandler.current(event);

433

434

// Add event listener (mocked for testing)

435

window.addEventListener?.(eventName, eventListener);

436

437

return () => {

438

window.removeEventListener?.(eventName, eventListener);

439

};

440

}, [eventName]);

441

};

442

443

const mockAddEventListener = jest.spyOn(window, 'addEventListener').mockImplementation();

444

const mockRemoveEventListener = jest.spyOn(window, 'removeEventListener').mockImplementation();

445

446

const handler = jest.fn();

447

448

const { unmount, rerender } = renderHook(useEventListener, {

449

initialProps: { eventName: "resize", handler }

450

});

451

452

// Event listener should be added

453

expect(mockAddEventListener).toHaveBeenCalledWith("resize", expect.any(Function));

454

455

// Change event name

456

rerender({ eventName: "scroll", handler });

457

458

// Should remove old listener and add new one

459

expect(mockRemoveEventListener).toHaveBeenCalledWith("resize", expect.any(Function));

460

expect(mockAddEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));

461

462

// Unmount should cleanup

463

unmount();

464

465

expect(mockRemoveEventListener).toHaveBeenCalledWith("scroll", expect.any(Function));

466

467

mockAddEventListener.mockRestore();

468

mockRemoveEventListener.mockRestore();

469

});

470

471

test("hook with reducer pattern", () => {

472

const useCounter = () => {

473

const [state, dispatch] = useReducer((state, action) => {

474

switch (action.type) {

475

case "increment":

476

return { count: state.count + (action.payload || 1) };

477

case "decrement":

478

return { count: state.count - (action.payload || 1) };

479

case "reset":

480

return { count: action.payload || 0 };

481

default:

482

return state;

483

}

484

}, { count: 0 });

485

486

const increment = (amount) => dispatch({ type: "increment", payload: amount });

487

const decrement = (amount) => dispatch({ type: "decrement", payload: amount });

488

const reset = (value) => dispatch({ type: "reset", payload: value });

489

490

return {

491

count: state.count,

492

increment,

493

decrement,

494

reset

495

};

496

};

497

498

const { result } = renderHook(useCounter);

499

500

// Initial state

501

expect(result.current.count).toBe(0);

502

503

// Increment by 1 (default)

504

act(() => {

505

result.current.increment();

506

});

507

expect(result.current.count).toBe(1);

508

509

// Increment by custom amount

510

act(() => {

511

result.current.increment(5);

512

});

513

expect(result.current.count).toBe(6);

514

515

// Decrement

516

act(() => {

517

result.current.decrement(2);

518

});

519

expect(result.current.count).toBe(4);

520

521

// Reset to custom value

522

act(() => {

523

result.current.reset(10);

524

});

525

expect(result.current.count).toBe(10);

526

527

// Reset to default (0)

528

act(() => {

529

result.current.reset();

530

});

531

expect(result.current.count).toBe(0);

532

});

533

```

534

535

### Hook Testing Best Practices

536

537

Guidelines and patterns for effective hook testing.

538

539

```typescript { .api }

540

/**

541

* Best practices for hook testing

542

*/

543

```

544

545

**Usage Examples:**

546

547

```typescript

548

test("testing hook with external dependencies", () => {

549

// Mock external dependencies

550

const mockFetch = jest.fn();

551

global.fetch = mockFetch;

552

553

const useApi = (url) => {

554

const [data, setData] = useState(null);

555

const [loading, setLoading] = useState(false);

556

const [error, setError] = useState(null);

557

558

const fetchData = useCallback(async () => {

559

setLoading(true);

560

setError(null);

561

562

try {

563

const response = await fetch(url);

564

const result = await response.json();

565

setData(result);

566

} catch (err) {

567

setError(err.message);

568

} finally {

569

setLoading(false);

570

}

571

}, [url]);

572

573

useEffect(() => {

574

fetchData();

575

}, [fetchData]);

576

577

return { data, loading, error, refetch: fetchData };

578

};

579

580

// Mock successful response

581

mockFetch.mockResolvedValueOnce({

582

json: () => Promise.resolve({ id: 1, name: "Test" })

583

});

584

585

const { result } = renderHook(useApi, {

586

initialProps: "/api/test"

587

});

588

589

expect(result.current.loading).toBe(true);

590

591

// Wait for fetch to complete

592

return waitFor(() => {

593

expect(result.current.loading).toBe(false);

594

expect(result.current.data).toEqual({ id: 1, name: "Test" });

595

expect(result.current.error).toBeNull();

596

});

597

});

598

599

test("testing hook error boundaries", () => {

600

const useRiskyHook = (shouldThrow) => {

601

const [value, setValue] = useState("safe");

602

603

useEffect(() => {

604

if (shouldThrow) {

605

throw new Error("Hook error");

606

}

607

}, [shouldThrow]);

608

609

return value;

610

};

611

612

// Test safe usage

613

const { result, rerender } = renderHook(useRiskyHook, {

614

initialProps: false

615

});

616

617

expect(result.current).toBe("safe");

618

619

// Test error case - should be caught by error boundary

620

expect(() => {

621

rerender(true);

622

}).toThrow("Hook error");

623

});

624

625

test("testing hook performance", () => {

626

const useExpensiveCalculation = (input) => {

627

const expensiveFunction = useCallback((n) => {

628

// Simulate expensive calculation

629

let result = 0;

630

for (let i = 0; i < n; i++) {

631

result += i;

632

}

633

return result;

634

}, []);

635

636

const memoizedResult = useMemo(() => {

637

return expensiveFunction(input);

638

}, [input, expensiveFunction]);

639

640

return memoizedResult;

641

};

642

643

const expensiveFunctionSpy = jest.fn((n) => {

644

let result = 0;

645

for (let i = 0; i < n; i++) {

646

result += i;

647

}

648

return result;

649

});

650

651

// Test with memoization

652

const { result, rerender } = renderHook(useExpensiveCalculation, {

653

initialProps: 1000

654

});

655

656

const firstResult = result.current;

657

expect(typeof firstResult).toBe("number");

658

659

// Re-render with same props - should not recalculate

660

rerender(1000);

661

expect(result.current).toBe(firstResult);

662

663

// Re-render with different props - should recalculate

664

rerender(2000);

665

expect(result.current).not.toBe(firstResult);

666

});

667

```

668

669

### Concurrent Mode and Suspense

670

671

Testing hooks in concurrent mode and with Suspense boundaries.

672

673

```typescript { .api }

674

/**

675

* Hook testing with concurrent features

676

*/

677

```

678

679

**Usage Examples:**

680

681

```typescript

682

test("hook with concurrent rendering", async () => {

683

const useConcurrentData = (query) => {

684

const [data, setData] = useState(null);

685

686

// Use transition for non-urgent updates

687

const [isPending, startTransition] = useTransition();

688

689

const updateData = (newQuery) => {

690

startTransition(() => {

691

// Expensive state update

692

setData(`Result for ${newQuery}`);

693

});

694

};

695

696

useEffect(() => {

697

updateData(query);

698

}, [query]);

699

700

return { data, isPending, updateData };

701

};

702

703

const { result } = renderHook(useConcurrentData, {

704

initialProps: "initial query",

705

concurrentRoot: true

706

});

707

708

await waitFor(() => {

709

expect(result.current.data).toBe("Result for initial query");

710

});

711

712

expect(result.current.isPending).toBe(false);

713

714

// Test pending state during transition

715

act(() => {

716

result.current.updateData("new query");

717

});

718

719

// Might be pending briefly

720

if (result.current.isPending) {

721

await waitFor(() => {

722

expect(result.current.isPending).toBe(false);

723

});

724

}

725

726

expect(result.current.data).toBe("Result for new query");

727

});

728

729

test("hook with Suspense", async () => {

730

const cache = new Map();

731

732

const useSuspenseData = (key) => {

733

if (!cache.has(key)) {

734

const promise = new Promise(resolve => {

735

setTimeout(() => resolve(`Data for ${key}`), 100);

736

});

737

cache.set(key, promise);

738

throw promise;

739

}

740

741

const data = cache.get(key);

742

if (data instanceof Promise) {

743

throw data;

744

}

745

746

return data;

747

};

748

749

const SuspenseWrapper = ({ children }) => (

750

<Suspense fallback={<Text>Loading...</Text>}>

751

{children}

752

</Suspense>

753

);

754

755

// This will initially throw and trigger Suspense

756

const hookPromise = renderHookAsync(useSuspenseData, {

757

initialProps: "test-key",

758

wrapper: SuspenseWrapper

759

});

760

761

// Wait for Suspense to resolve

762

const { result } = await hookPromise;

763

764

await waitFor(() => {

765

expect(result.current).toBe("Data for test-key");

766

});

767

});

768

```