diff --git a/examples/cookbook/app/index.tsx b/examples/cookbook/app/index.tsx index 025a57d2..06564b31 100644 --- a/examples/cookbook/app/index.tsx +++ b/examples/cookbook/app/index.tsx @@ -82,6 +82,7 @@ type Recipe = { }; const recipes: Recipe[] = [ - { id: 2, title: 'Welcome Screen with Custom Render', path: 'custom-render/' }, - { id: 1, title: 'Task List with Jotai', path: 'jotai/' }, + { id: 1, title: 'Welcome Screen with Custom Render', path: 'custom-render/' }, + { id: 2, title: 'Task List with Jotai', path: 'state-management/jotai/' }, + { id: 3, title: 'Phone book with\na Variety of Net. Req. Methods', path: 'advanced/' }, ]; diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx new file mode 100644 index 00000000..fe25520d --- /dev/null +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react'; +import { Text } from 'react-native'; +import { User } from './types'; +import ContactsList from './components/ContactsList'; +import FavoritesList from './components/FavoritesList'; +import getAllContacts from './api/getAllContacts'; +import getAllFavorites from './api/getAllFavorites'; + +export default () => { + const [usersData, setUsersData] = useState([]); + const [favoritesData, setFavoritesData] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const _getAllContacts = async () => { + const _data = await getAllContacts(); + setUsersData(_data); + }; + const _getAllFavorites = async () => { + const _data = await getAllFavorites(); + setFavoritesData(_data); + }; + + const run = async () => { + try { + await Promise.all([_getAllContacts(), _getAllFavorites()]); + } catch (e) { + const message = isErrorWithMessage(e) ? e.message : 'Something went wrong'; + setError(message); + } + }; + + void run(); + }, []); + + if (error) { + return An error occurred: {error}; + } + + return ( + <> + + + + ); +}; + +const isErrorWithMessage = ( + e: unknown, +): e is { + message: string; +} => typeof e === 'object' && e !== null && 'message' in e; diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx new file mode 100644 index 00000000..f3665391 --- /dev/null +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; +import React from 'react'; +import PhoneBook from '../PhoneBook'; +import { + mockServerFailureForGetAllContacts, + mockServerFailureForGetAllFavorites, +} from './test-utils'; + +jest.setTimeout(10000); + +describe('PhoneBook', () => { + it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + expect(await screen.findByText('Name: Mrs Ida Kristensen')).toBeOnTheScreen(); + expect(await screen.findByText('Email: ida.kristensen@example.com')).toBeOnTheScreen(); + expect(await screen.findAllByText(/name/i)).toHaveLength(3); + expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); + expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); + }); + + it('fails to fetch all contacts and renders error message', async () => { + mockServerFailureForGetAllContacts(); + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + expect( + await screen.findByText(/an error occurred: error fetching contacts/i), + ).toBeOnTheScreen(); + }); + + it('fails to fetch favorites and renders error message', async () => { + mockServerFailureForGetAllFavorites(); + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen(); + }); +}); diff --git a/examples/cookbook/app/network-requests/__tests__/test-utils.ts b/examples/cookbook/app/network-requests/__tests__/test-utils.ts new file mode 100644 index 00000000..c14edd6b --- /dev/null +++ b/examples/cookbook/app/network-requests/__tests__/test-utils.ts @@ -0,0 +1,109 @@ +import { User } from '../types'; +import {http, HttpResponse} from "msw"; +import {setupServer} from "msw/node"; + +// Define request handlers and response resolvers for random user API. +// By default, we always return the happy path response. +const handlers = [ + http.get('https://randomuser.me/api/*', () => { + return HttpResponse.json(DATA); + }), +]; + +export const server = setupServer(...handlers); + +export const mockServerFailureForGetAllContacts = () => { + server.use( + http.get('https://randomuser.me/api/', ({ request }) => { + // Construct a URL instance out of the intercepted request. + const url = new URL(request.url); + // Read the "results" URL query parameter using the "URLSearchParams" API. + const resultsLength = url.searchParams.get('results'); + // Simulate a server error for the get all contacts request. + // We check if the "results" query parameter is set to "25" + // to know it's the correct request to mock, in our case get all contacts. + if (resultsLength === '25') { + return new HttpResponse(null, { status: 500 }); + } + + return HttpResponse.json(DATA); + }), + ); +}; + +export const mockServerFailureForGetAllFavorites = () => { + server.use( + http.get('https://randomuser.me/api/', ({ request }) => { + // Construct a URL instance out of the intercepted request. + const url = new URL(request.url); + // Read the "results" URL query parameter using the "URLSearchParams" API. + const resultsLength = url.searchParams.get('results'); + // Simulate a server error for the get all favorites request. + // We check if the "results" query parameter is set to "10" + // to know it's the correct request to mock, in our case get all favorites. + if (resultsLength === '10') { + return new HttpResponse(null, { status: 500 }); + } + + return HttpResponse.json(DATA); + }), + ); +}; +export const DATA: { results: User[] } = { + results: [ + { + name: { + title: 'Mrs', + first: 'Ida', + last: 'Kristensen', + }, + email: 'ida.kristensen@example.com', + id: { + name: 'CPR', + value: '250562-5730', + }, + picture: { + large: 'https://randomuser.me/api/portraits/women/26.jpg', + medium: 'https://randomuser.me/api/portraits/med/women/26.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/women/26.jpg', + }, + cell: '123-4567-890', + }, + { + name: { + title: 'Mr', + first: 'Elijah', + last: 'Ellis', + }, + email: 'elijah.ellis@example.com', + id: { + name: 'TFN', + value: '138117486', + }, + picture: { + large: 'https://randomuser.me/api/portraits/men/53.jpg', + medium: 'https://randomuser.me/api/portraits/med/men/53.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/men/53.jpg', + }, + cell: '123-4567-890', + }, + { + name: { + title: 'Mr', + first: 'Miro', + last: 'Halko', + }, + email: 'miro.halko@example.com', + id: { + name: 'HETU', + value: 'NaNNA945undefined', + }, + picture: { + large: 'https://randomuser.me/api/portraits/men/17.jpg', + medium: 'https://randomuser.me/api/portraits/med/men/17.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/men/17.jpg', + }, + cell: '123-4567-890', + }, + ], +}; diff --git a/examples/cookbook/app/network-requests/api/getAllContacts.ts b/examples/cookbook/app/network-requests/api/getAllContacts.ts new file mode 100644 index 00000000..118f242d --- /dev/null +++ b/examples/cookbook/app/network-requests/api/getAllContacts.ts @@ -0,0 +1,10 @@ +import { User } from '../types'; + +export default async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=25'); + if (!res.ok) { + throw new Error(`Error fetching contacts`); + } + const json = await res.json(); + return json.results; +}; diff --git a/examples/cookbook/app/network-requests/api/getAllFavorites.ts b/examples/cookbook/app/network-requests/api/getAllFavorites.ts new file mode 100644 index 00000000..954d11dc --- /dev/null +++ b/examples/cookbook/app/network-requests/api/getAllFavorites.ts @@ -0,0 +1,10 @@ +import { User } from '../types'; + +export default async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=10'); + if (!res.ok) { + throw new Error(`Error fetching favorites`); + } + const json = await res.json(); + return json.results; +}; diff --git a/examples/cookbook/app/network-requests/components/ContactsList.tsx b/examples/cookbook/app/network-requests/components/ContactsList.tsx new file mode 100644 index 00000000..f62f99f0 --- /dev/null +++ b/examples/cookbook/app/network-requests/components/ContactsList.tsx @@ -0,0 +1,60 @@ +import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; +import React, { useCallback } from 'react'; +import type { ListRenderItem } from '@react-native/virtualized-lists'; +import { User } from '../types'; + +export default ({ users }: { users: User[] }) => { + const renderItem: ListRenderItem = useCallback( + ({ item: { name, email, picture, cell }, index }) => { + const { title, first, last } = name; + const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff'; + return ( + + + + + Name: {title} {first} {last} + + Email: {email} + Mobile: {cell} + + + ); + }, + [], + ); + + if (users.length === 0) return ; + + return ( + + + data={users} + renderItem={renderItem} + keyExtractor={(item, index) => `${index}-${item.id.value}`} + /> + + ); +}; +const FullScreenLoader = () => { + return ( + + Users data not quite there yet... + + ); +}; + +const styles = StyleSheet.create({ + userContainer: { + padding: 16, + flexDirection: 'row', + alignItems: 'center', + }, + userImage: { + width: 50, + height: 50, + borderRadius: 24, + marginRight: 16, + }, + loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, +}); diff --git a/examples/cookbook/app/network-requests/components/FavoritesList.tsx b/examples/cookbook/app/network-requests/components/FavoritesList.tsx new file mode 100644 index 00000000..17503200 --- /dev/null +++ b/examples/cookbook/app/network-requests/components/FavoritesList.tsx @@ -0,0 +1,59 @@ +import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; +import React, { useCallback } from 'react'; +import type { ListRenderItem } from '@react-native/virtualized-lists'; +import { User } from '../types'; + +export default ({ users }: { users: User[] }) => { + const renderItem: ListRenderItem = useCallback(({ item: { picture } }) => { + return ( + + + + ); + }, []); + + if (users.length === 0) return ; + + return ( + + ⭐My Favorites + + horizontal + showsHorizontalScrollIndicator={false} + data={users} + renderItem={renderItem} + keyExtractor={(item, index) => `${index}-${item.id.value}`} + /> + + ); +}; +const FullScreenLoader = () => { + return ( + + Figuring out your favorites... + + ); +}; + +const styles = StyleSheet.create({ + outerContainer: { + padding: 8, + }, + userContainer: { + padding: 8, + flexDirection: 'row', + alignItems: 'center', + }, + userImage: { + width: 52, + height: 52, + borderRadius: 36, + borderColor: '#9b6dff', + borderWidth: 2, + }, + loaderContainer: { height: 52, justifyContent: 'center', alignItems: 'center' }, +}); diff --git a/examples/cookbook/app/network-requests/index.tsx b/examples/cookbook/app/network-requests/index.tsx new file mode 100644 index 00000000..86075de3 --- /dev/null +++ b/examples/cookbook/app/network-requests/index.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import PhoneBook from './PhoneBook'; + +export default function Example() { + return ; +} diff --git a/examples/cookbook/app/network-requests/types.ts b/examples/cookbook/app/network-requests/types.ts new file mode 100644 index 00000000..f198d644 --- /dev/null +++ b/examples/cookbook/app/network-requests/types.ts @@ -0,0 +1,18 @@ +export type User = { + name: { + title: string; + first: string; + last: string; + }; + email: string; + id: { + name: string; + value: string; + }; + picture: { + large: string; + medium: string; + thumbnail: string; + }; + cell: string; +}; diff --git a/examples/cookbook/jest-setup.ts b/examples/cookbook/jest-setup.ts index 7f63025d..d5160525 100644 --- a/examples/cookbook/jest-setup.ts +++ b/examples/cookbook/jest-setup.ts @@ -2,6 +2,14 @@ // Import built-in Jest matchers import '@testing-library/react-native/extend-expect'; +import { server } from './app/network-requests/__tests__/test-utils'; // Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); + +// Enable API mocking via Mock Service Worker (MSW) +beforeAll(() => server.listen()); +// Reset any runtime request handlers we may add during the tests +afterEach(() => server.resetHandlers()); +// Disable API mocking after the tests are done +afterAll(() => server.close()); diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 2468698c..71d4fb02 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -37,6 +37,7 @@ "@types/react-native-get-random-values": "^1", "eslint": "^8.57.0", "jest": "^29.7.0", + "msw": "^2.4.4", "react-test-renderer": "18.2.0", "typescript": "~5.3.3" }, diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index 66fef55c..09a29ff8 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -1137,6 +1137,34 @@ __metadata: languageName: node linkType: hard +"@bundled-es-modules/cookie@npm:^2.0.0": + version: 2.0.0 + resolution: "@bundled-es-modules/cookie@npm:2.0.0" + dependencies: + cookie: "npm:^0.5.0" + checksum: 10c0/0655dd331b35d7b5b6dd2301c3bcfb7233018c0e3235a40ced1d53f00463ab92dc01f0091f153812867bc0ef0f8e0a157a30acb16e8d7ef149702bf8db9fe7a6 + languageName: node + linkType: hard + +"@bundled-es-modules/statuses@npm:^1.0.1": + version: 1.0.1 + resolution: "@bundled-es-modules/statuses@npm:1.0.1" + dependencies: + statuses: "npm:^2.0.1" + checksum: 10c0/c1a8ede3efa8da61ccda4b98e773582a9733edfbeeee569d4630785f8e018766202edb190a754a3ec7a7f6bd738e857829affc2fdb676b6dab4db1bb44e62785 + languageName: node + linkType: hard + +"@bundled-es-modules/tough-cookie@npm:^0.1.6": + version: 0.1.6 + resolution: "@bundled-es-modules/tough-cookie@npm:0.1.6" + dependencies: + "@types/tough-cookie": "npm:^4.0.5" + tough-cookie: "npm:^4.1.4" + checksum: 10c0/28bcac878bff6b34719ba3aa8341e9924772ee55de5487680ebe784981ec9fccb70ed5d46f563e2404855a04de606f9e56aa4202842d4f5835bc04a4fe820571 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -1630,6 +1658,53 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^3.0.0": + version: 3.2.0 + resolution: "@inquirer/confirm@npm:3.2.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + checksum: 10c0/a2cbfc8ae9c880bba4cce1993f5c399fb0d12741fdd574917c87fceb40ece62ffa60e35aaadf4e62d7c114f54008e45aee5d6d90497bb62d493996c02725d243 + languageName: node + linkType: hard + +"@inquirer/core@npm:^9.1.0": + version: 9.1.0 + resolution: "@inquirer/core@npm:9.1.0" + dependencies: + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.3" + "@types/mute-stream": "npm:^0.0.4" + "@types/node": "npm:^22.5.2" + "@types/wrap-ansi": "npm:^3.0.0" + ansi-escapes: "npm:^4.3.2" + cli-spinners: "npm:^2.9.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^1.0.0" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/c86cbd1980788dee4151002ed717b5664a79eec1d925e1b38896bbad079647af5c423eaaa39a2291ba4fdf78a33c541ea3f69cbbf030f03815eb523fa05230f8 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.5": + version: 1.0.5 + resolution: "@inquirer/figures@npm:1.0.5" + checksum: 10c0/ec9ba23db42cb33fa18eb919abf2a18e750e739e64c1883ce4a98345cd5711c60cac12d1faf56a859f52d387deb221c8d3dfe60344ee07955a9a262f8b821fe3 + languageName: node + linkType: hard + +"@inquirer/type@npm:^1.5.3": + version: 1.5.3 + resolution: "@inquirer/type@npm:1.5.3" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 10c0/da92a7410efcb20cf12422558fb8e00136e2ff1746ae1d17ea05511e77139bf2044527d37a70e77f188f158099f7751ed808ca3f82769cbe99c1052509481e95 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1975,6 +2050,20 @@ __metadata: languageName: node linkType: hard +"@mswjs/interceptors@npm:^0.35.0": + version: 0.35.0 + resolution: "@mswjs/interceptors@npm:0.35.0" + dependencies: + "@open-draft/deferred-promise": "npm:^2.2.0" + "@open-draft/logger": "npm:^0.3.0" + "@open-draft/until": "npm:^2.0.0" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.3" + strict-event-emitter: "npm:^0.5.1" + checksum: 10c0/7e1a03a32afb9dafd6bdd8a77b838d4c6cff61a9d0aecf76a898ca2715c9068a8dfd3a40869dfaf42c834bc833fc057ccc096c2819c8f9a3212469fddc543b66 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2024,6 +2113,30 @@ __metadata: languageName: node linkType: hard +"@open-draft/deferred-promise@npm:^2.2.0": + version: 2.2.0 + resolution: "@open-draft/deferred-promise@npm:2.2.0" + checksum: 10c0/eafc1b1d0fc8edb5e1c753c5e0f3293410b40dde2f92688211a54806d4136887051f39b98c1950370be258483deac9dfd17cf8b96557553765198ef2547e4549 + languageName: node + linkType: hard + +"@open-draft/logger@npm:^0.3.0": + version: 0.3.0 + resolution: "@open-draft/logger@npm:0.3.0" + dependencies: + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.0" + checksum: 10c0/90010647b22e9693c16258f4f9adb034824d1771d3baa313057b9a37797f571181005bc50415a934eaf7c891d90ff71dcd7a9d5048b0b6bb438f31bef2c7c5c1 + languageName: node + linkType: hard + +"@open-draft/until@npm:^2.0.0, @open-draft/until@npm:^2.1.0": + version: 2.1.0 + resolution: "@open-draft/until@npm:2.1.0" + checksum: 10c0/61d3f99718dd86bb393fee2d7a785f961dcaf12f2055f0c693b27f4d0cd5f7a03d498a6d9289773b117590d794a43cd129366fd8e99222e4832f67b1653d54cf + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -2855,6 +2968,15 @@ __metadata: languageName: node linkType: hard +"@types/mute-stream@npm:^0.0.4": + version: 0.0.4 + resolution: "@types/mute-stream@npm:0.0.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/944730fd7b398c5078de3c3d4d0afeec8584283bc694da1803fdfca14149ea385e18b1b774326f1601baf53898ce6d121a952c51eb62d188ef6fcc41f725c0dc + languageName: node + linkType: hard + "@types/node-forge@npm:^1.3.0": version: 1.3.11 resolution: "@types/node-forge@npm:1.3.11" @@ -2882,6 +3004,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.5.2": + version: 22.5.4 + resolution: "@types/node@npm:22.5.4" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 10c0/b445daa7eecd761ad4d778b882d6ff7bcc3b4baad2086ea9804db7c5d4a4ab0298b00d7f5315fc640a73b5a1d52bbf9628e09c9fec0cf44dbf9b4df674a8717d + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.12 resolution: "@types/prop-types@npm:15.7.12" @@ -2913,6 +3044,27 @@ __metadata: languageName: node linkType: hard +"@types/statuses@npm:^2.0.4": + version: 2.0.5 + resolution: "@types/statuses@npm:2.0.5" + checksum: 10c0/4dacec0b29483a44be902a022a11a22b339de7a6e7b2059daa4f7add10cb6dbcc28d02d2a416fe9687e48d335906bf983065391836d4e7c847e55ddef4de8fad + languageName: node + linkType: hard + +"@types/tough-cookie@npm:^4.0.5": + version: 4.0.5 + resolution: "@types/tough-cookie@npm:4.0.5" + checksum: 10c0/68c6921721a3dcb40451543db2174a145ef915bc8bcbe7ad4e59194a0238e776e782b896c7a59f4b93ac6acefca9161fccb31d1ce3b3445cb6faa467297fb473 + languageName: node + linkType: hard + +"@types/wrap-ansi@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/wrap-ansi@npm:3.0.0" + checksum: 10c0/8d8f53363f360f38135301a06b596c295433ad01debd082078c33c6ed98b05a5c8fe8853a88265432126096084f4a135ec1564e3daad631b83296905509f90b3 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -3872,13 +4024,20 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.0.0, cli-spinners@npm:^2.5.0": +"cli-spinners@npm:^2.0.0, cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10c0/907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f + languageName: node + linkType: hard + "cliui@npm:^6.0.0": version: 6.0.0 resolution: "cliui@npm:6.0.0" @@ -4114,6 +4273,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.5.0": + version: 0.5.0 + resolution: "cookie@npm:0.5.0" + checksum: 10c0/c01ca3ef8d7b8187bae434434582288681273b5a9ed27521d4d7f9f7928fe0c920df0decd9f9d3bbd2d14ac432b8c8cf42b98b3bdd5bfe0e6edddeebebe8b61d + languageName: node + linkType: hard + "cookie@npm:^0.6.0": version: 0.6.0 resolution: "cookie@npm:0.6.0" @@ -5763,6 +5929,13 @@ __metadata: languageName: node linkType: hard +"graphql@npm:^16.8.1": + version: 16.9.0 + resolution: "graphql@npm:16.9.0" + checksum: 10c0/a8850f077ff767377237d1f8b1da2ec70aeb7623cdf1dfc9e1c7ae93accc0c8149c85abe68923be9871a2934b1bce5a2496f846d4d56e1cfb03eaaa7ddba9b6a + languageName: node + linkType: hard + "has-bigints@npm:^1.0.1, has-bigints@npm:^1.0.2": version: 1.0.2 resolution: "has-bigints@npm:1.0.2" @@ -5825,6 +5998,13 @@ __metadata: languageName: node linkType: hard +"headers-polyfill@npm:^4.0.2": + version: 4.0.3 + resolution: "headers-polyfill@npm:4.0.3" + checksum: 10c0/53e85b2c6385f8d411945fb890c5369f1469ce8aa32a6e8d28196df38568148de640c81cf88cbc7c67767103dd9acba48f4f891982da63178fc6e34560022afe + languageName: node + linkType: hard + "hermes-estree@npm:0.19.1": version: 0.19.1 resolution: "hermes-estree@npm:0.19.1" @@ -6316,6 +6496,13 @@ __metadata: languageName: node linkType: hard +"is-node-process@npm:^1.2.0": + version: 1.2.0 + resolution: "is-node-process@npm:1.2.0" + checksum: 10c0/5b24fda6776d00e42431d7bcd86bce81cb0b6cabeb944142fe7b077a54ada2e155066ad06dbe790abdb397884bdc3151e04a9707b8cd185099efbc79780573ed + languageName: node + linkType: hard + "is-number-object@npm:^1.0.4": version: 1.0.7 resolution: "is-number-object@npm:1.0.7" @@ -8105,6 +8292,45 @@ __metadata: languageName: node linkType: hard +"msw@npm:^2.4.4": + version: 2.4.4 + resolution: "msw@npm:2.4.4" + dependencies: + "@bundled-es-modules/cookie": "npm:^2.0.0" + "@bundled-es-modules/statuses": "npm:^1.0.1" + "@bundled-es-modules/tough-cookie": "npm:^0.1.6" + "@inquirer/confirm": "npm:^3.0.0" + "@mswjs/interceptors": "npm:^0.35.0" + "@open-draft/until": "npm:^2.1.0" + "@types/cookie": "npm:^0.6.0" + "@types/statuses": "npm:^2.0.4" + chalk: "npm:^4.1.2" + graphql: "npm:^16.8.1" + headers-polyfill: "npm:^4.0.2" + is-node-process: "npm:^1.2.0" + outvariant: "npm:^1.4.2" + path-to-regexp: "npm:^6.2.0" + strict-event-emitter: "npm:^0.5.1" + type-fest: "npm:^4.9.0" + yargs: "npm:^17.7.2" + peerDependencies: + typescript: ">= 4.8.x" + peerDependenciesMeta: + typescript: + optional: true + bin: + msw: cli/index.js + checksum: 10c0/0be51fbd6ef1b4fd8906353b6e40473f9466ea7ef9cc805e49c5937886721db20801b2322535b456d523040c012afb34e0e76a5beaa4891cbdde2408d2652094 + languageName: node + linkType: hard + +"mute-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "mute-stream@npm:1.0.0" + checksum: 10c0/dce2a9ccda171ec979a3b4f869a102b1343dee35e920146776780de182f16eae459644d187e38d59a3d37adf85685e1c17c38cf7bfda7e39a9880f7a1d10a74c + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -8493,6 +8719,13 @@ __metadata: languageName: node linkType: hard +"outvariant@npm:^1.4.0, outvariant@npm:^1.4.2, outvariant@npm:^1.4.3": + version: 1.4.3 + resolution: "outvariant@npm:1.4.3" + checksum: 10c0/5976ca7740349cb8c71bd3382e2a762b1aeca6f33dc984d9d896acdf3c61f78c3afcf1bfe9cc633a7b3c4b295ec94d292048f83ea2b2594fae4496656eba992c + languageName: node + linkType: hard + "p-finally@npm:^1.0.0": version: 1.0.0 resolution: "p-finally@npm:1.0.0" @@ -8677,6 +8910,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^6.2.0": + version: 6.2.2 + resolution: "path-to-regexp@npm:6.2.2" + checksum: 10c0/4b60852d3501fd05ca9dd08c70033d73844e5eca14e41f499f069afa8364f780f15c5098002f93bd42af8b3514de62ac6e82a53b5662de881d2b08c9ef21ea6b + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -8887,6 +9127,13 @@ __metadata: languageName: node linkType: hard +"psl@npm:^1.1.33": + version: 1.9.0 + resolution: "psl@npm:1.9.0" + checksum: 10c0/6a3f805fdab9442f44de4ba23880c4eba26b20c8e8e0830eff1cb31007f6825dace61d17203c58bfe36946842140c97a1ba7f67bc63ca2d88a7ee052b65d97ab + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.0 resolution: "pump@npm:3.0.0" @@ -8939,6 +9186,13 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -9356,6 +9610,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + "resolve-cwd@npm:^3.0.0": version: 3.0.0 resolution: "resolve-cwd@npm:3.0.0" @@ -9513,6 +9774,7 @@ __metadata: expo-status-bar: "npm:~1.12.1" jest: "npm:^29.7.0" jotai: "npm:^2.8.4" + msw: "npm:^2.4.4" nanoid: "npm:^3.3.7" react: "npm:18.2.0" react-dom: "npm:18.2.0" @@ -9821,7 +10083,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 @@ -10020,7 +10282,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:2.0.1": +"statuses@npm:2.0.1, statuses@npm:^2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1" checksum: 10c0/34378b207a1620a24804ce8b5d230fea0c279f00b18a7209646d5d47e419d1cc23e7cbf33a25a1e51ac38973dc2ac2e1e9c647a8e481ef365f77668d72becfd0 @@ -10048,6 +10310,13 @@ __metadata: languageName: node linkType: hard +"strict-event-emitter@npm:^0.5.1": + version: 0.5.1 + resolution: "strict-event-emitter@npm:0.5.1" + checksum: 10c0/f5228a6e6b6393c57f52f62e673cfe3be3294b35d6f7842fc24b172ae0a6e6c209fa83241d0e433fc267c503bc2f4ffdbe41a9990ff8ffd5ac425ec0489417f7 + languageName: node + linkType: hard + "strict-uri-encode@npm:^2.0.0": version: 2.0.0 resolution: "strict-uri-encode@npm:2.0.0" @@ -10498,6 +10767,18 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^4.1.4": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10c0/aca7ff96054f367d53d1e813e62ceb7dd2eda25d7752058a74d64b7266fd07be75908f3753a32ccf866a2f997604b414cfb1916d6e7f69bc64d9d9939b0d6c45 + languageName: node + linkType: hard + "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" @@ -10588,6 +10869,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^4.9.0": + version: 4.26.1 + resolution: "type-fest@npm:4.26.1" + checksum: 10c0/d2719ff8d380befe8a3c61068f37f28d6fa2849fd140c5d2f0f143099e371da6856aad7c97e56b83329d45bfe504afe9fd936a7cff600cc0d46aa9ffb008d6c6 + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.2": version: 1.0.2 resolution: "typed-array-buffer@npm:1.0.2" @@ -10707,6 +10995,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + "undici@npm:^6.11.1": version: 6.19.7 resolution: "undici@npm:6.19.7" @@ -10788,6 +11083,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10c0/cedbe4d4ca3967edf24c0800cfc161c5a15e240dac28e3ce575c689abc11f2c81ccc6532c8752af3b40f9120fb5e454abecd359e164f4f6aa44c29cd37e194fe + languageName: node + linkType: hard + "universalify@npm:^1.0.0": version: 1.0.0 resolution: "universalify@npm:1.0.0" @@ -10839,6 +11141,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + "use-latest-callback@npm:^0.2.1": version: 0.2.1 resolution: "use-latest-callback@npm:0.2.1" @@ -11329,7 +11641,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1, yargs@npm:^17.6.2": +"yargs@npm:^17.3.1, yargs@npm:^17.6.2, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -11350,3 +11662,10 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"yoctocolors-cjs@npm:^2.1.2": + version: 2.1.2 + resolution: "yoctocolors-cjs@npm:2.1.2" + checksum: 10c0/a0e36eb88fea2c7981eab22d1ba45e15d8d268626e6c4143305e2c1628fa17ebfaa40cd306161a8ce04c0a60ee0262058eab12567493d5eb1409780853454c6f + languageName: node + linkType: hard diff --git a/website/docs/12.x/cookbook/_meta.json b/website/docs/12.x/cookbook/_meta.json index 2aae9707..deb5689d 100644 --- a/website/docs/12.x/cookbook/_meta.json +++ b/website/docs/12.x/cookbook/_meta.json @@ -5,6 +5,11 @@ "name": "basics", "label": "Basic Recipes" }, + { + "type": "dir", + "name": "advanced", + "label": "Advanced Recipes" + }, { "type": "dir", "name": "state-management", diff --git a/website/docs/12.x/cookbook/advanced/_meta.json b/website/docs/12.x/cookbook/advanced/_meta.json new file mode 100644 index 00000000..9d0399cc --- /dev/null +++ b/website/docs/12.x/cookbook/advanced/_meta.json @@ -0,0 +1 @@ +["network-requests"] diff --git a/website/docs/12.x/cookbook/advanced/network-requests.md b/website/docs/12.x/cookbook/advanced/network-requests.md new file mode 100644 index 00000000..08002ef9 --- /dev/null +++ b/website/docs/12.x/cookbook/advanced/network-requests.md @@ -0,0 +1,380 @@ +# Network Requests + +## Introduction + +Mocking network requests is an essential part of testing React Native applications. By mocking +network +requests, you can control the data that is returned from the server and test how your application +behaves in different scenarios, such as when the request is successful or when it fails. + +In this guide, we will show you how to mock network requests and guard your test suits from unwanted +and unmocked/unhandled network requests + +:::info +To simulate a real-world scenario, we will use the [Random User Generator API](https://randomuser.me/) that provides random user data. +::: + +## Phonebook Example + +Let's assume we have a simple phonebook application that +uses [`fetch`](https://reactnative.dev/docs/network#using-fetch) for fetching Data from a server. +In our case, we have a list of contacts and favorites that we want to display in our application. + +This is how the root of the application looks like: + +```tsx title=network-requests/Phonebook.tsx +import React, { useEffect, useState } from 'react'; +import { Text } from 'react-native'; +import { User } from './types'; +import ContactsList from './components/ContactsList'; +import FavoritesList from './components/FavoritesList'; +import getAllContacts from './api/getAllContacts'; +import getAllFavorites from './api/getAllFavorites'; + +export default () => { + const [usersData, setUsersData] = useState([]); + const [favoritesData, setFavoritesData] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const _getAllContacts = async () => { + const _data = await getAllContacts(); + setUsersData(_data); + }; + const _getAllFavorites = async () => { + const _data = await getAllFavorites(); + setFavoritesData(_data); + }; + + const run = async () => { + try { + await Promise.all([_getAllContacts(), _getAllFavorites()]); + } catch (e) { + const message = isErrorWithMessage(e) ? e.message : 'Something went wrong'; + setError(message); + } + }; + + void run(); + }, []); + + if (error) { + return An error occurred: {error}; + } + + return ( + <> + + + + ); +}; +``` + +We fetch the contacts from the server using the `getAllFavorites` function that utilizes `fetch`. + +```tsx title=network-requests/api/getAllContacts.ts +import { User } from '../types'; + +export default async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=25'); + if (!res.ok) { + throw new Error(`Error fetching contacts`); + } + const json = await res.json(); + return json.results; +}; +``` + +We have similar function for fetching the favorites, but this time limiting the results to 10. + +```tsx title=network-requests/api/getAllFavorites.ts +import { User } from '../types'; + +export default async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=10'); + if (!res.ok) { + throw new Error(`Error fetching favorites`); + } + const json = await res.json(); + return json.results; +}; +``` + +Our `FavoritesList` component is a simple component that displays the list of favorite contacts and +their avatars horizontally. + +```tsx title=network-requests/components/FavoritesList.tsx +import {FlatList, Image, StyleSheet, Text, View} from 'react-native'; +import React, {useCallback} from 'react'; +import type {ListRenderItem} from '@react-native/virtualized-lists'; +import {User} from '../types'; + +export default ({users}: { users: User[] }) => { + const renderItem: ListRenderItem = useCallback(({item: {picture}}) => { + return ( + + + + ); + }, []); + + if (users.length === 0) return ( + + Figuring out your favorites... + + ); + + return ( + + ⭐My Favorites + + horizontal + showsHorizontalScrollIndicator={false} + data={users} + renderItem={renderItem} + keyExtractor={(item, index) => `${index}-${item.id.value}`} + /> + + ); +}; + +// Looking for styles? +// Check examples/cookbook/app/advanced/components/FavoritesList.tsx +const styles = +... +``` + +Our `ContactsList` component is similar to the `FavoritesList` component, but it displays the list +of +all contacts vertically. + +```tsx title=network-requests/components/ContactsList.tsx +import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; +import React, { useCallback } from 'react'; +import type { ListRenderItem } from '@react-native/virtualized-lists'; +import { User } from '../types'; + +export default ({ users }: { users: User[] }) => { + const renderItem: ListRenderItem = useCallback( + ({ item: { name, email, picture, cell }, index }) => { + const { title, first, last } = name; + const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff'; + return ( + + + + + Name: {title} {first} {last} + + Email: {email} + Mobile: {cell} + + + ); + }, + [], + ); + + if (users.length === 0) return ; + + return ( + + + data={users} + renderItem={renderItem} + keyExtractor={(item, index) => `${index}-${item.id.value}`} + /> + + ); +}; + +// Looking for styles or FullScreenLoader component? +// Check examples/cookbook/app/advanced/components/ContactsList.tsx +const FullScreenLoader = () => ... +const styles = ... +``` + +## Start testing with a simple test + +In our initial test we would like to test if the `PhoneBook` component renders the `FavoritesList` +and `ContactsList` components correctly. +We will need to mock the network requests and their corresponding responses to ensure that the component behaves as +expected. To mock the network requests we will use [MSW (Mock Service Worker)](https://mswjs.io/docs/getting-started). + +:::note +We recommend using the Mock Service Worker (MSW) library to declaratively mock API communication in your tests instead of stubbing `fetch`, or relying on third-party adapters. +::: + +:::info +You can install MSW by running `npm install msw --save-dev` or `yarn add msw --dev`. +More info regarding installation can be found in [MSW's getting started guide](https://mswjs.io/docs/getting-started#step-1-install). + +Please make sure you're also aware of [MSW's setup guide](https://mswjs.io/docs/integrations/react-native). +Please be minded that the MSW's setup guide is potentially incomplete and might contain discrepancies/missing pieces. +::: + +```tsx title=network-requests/Phonebook.test.tsx +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; +import React from 'react'; +import PhoneBook from '../PhoneBook'; +import { User } from '../types'; +import {http, HttpResponse} from "msw"; +import {setupServer} from "msw/node"; + +// Define request handlers and response resolvers for random user API. +// By default, we always return the happy path response. +const handlers = [ + http.get('https://randomuser.me/api/*', () => { + return HttpResponse.json(DATA); + }), +]; + +// Setup a request interception server with the given request handlers. +const server = setupServer(...handlers); + +// Enable API mocking via Mock Service Worker (MSW) +beforeAll(() => server.listen()); +// Reset any runtime request handlers we may add during the tests +afterEach(() => server.resetHandlers()); +// Disable API mocking after the tests are done +afterAll(() => server.close()); + +describe('PhoneBook', () => { + it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + expect(await screen.findByText('Name: Mrs Ida Kristensen')).toBeOnTheScreen(); + expect(await screen.findByText('Email: ida.kristensen@example.com')).toBeOnTheScreen(); + expect(await screen.findAllByText(/name/i)).toHaveLength(3); + expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); + expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); + }); +}); + +const DATA: { results: User[] } = { + results: [ + { + name: { + title: 'Mrs', + first: 'Ida', + last: 'Kristensen', + }, + email: 'ida.kristensen@example.com', + id: { + name: 'CPR', + value: '250562-5730', + }, + picture: { + large: 'https://randomuser.me/api/portraits/women/26.jpg', + medium: 'https://randomuser.me/api/portraits/med/women/26.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/women/26.jpg', + }, + cell: '123-4567-890', + }, + // For brevity, we have omitted the rest of the users, you can still find them in + // examples/cookbook/app/network-requests/__tests__/test-utils.ts + ... + ], +}; +``` + +:::info +More info regarding how to describe the network using request handlers, intercepting a request and handling its response can be found in the [MSW's documentation](https://mswjs.io/docs/getting-started#step-2-describe). +::: + +## Testing error handling + +As we are dealing with network requests, and things can go wrong, we should also cover the case when +the API request fails. In this case, we would like to test how our application behaves when the API request fails. + +:::info +The nature of the network can be highly dynamic, which makes it challenging to describe it completely in a fixed list of request handlers. +MSW provides us the means to override any particular network behavior using the designated `.use()` API. +More info can be found in [MSW's Network behavior overrides documentation](https://mswjs.io/docs/best-practices/network-behavior-overrides) +::: + +```tsx title=network-requests/Phonebook.test.tsx +... + +const mockServerFailureForGetAllContacts = () => { + server.use( + http.get('https://randomuser.me/api/', ({ request }) => { + // Construct a URL instance out of the intercepted request. + const url = new URL(request.url); + // Read the "results" URL query parameter using the "URLSearchParams" API. + const resultsLength = url.searchParams.get('results'); + // Simulate a server error for the get all contacts request. + // We check if the "results" query parameter is set to "25" + // to know it's the correct request to mock, in our case get all contacts. + if (resultsLength === '25') { + return new HttpResponse(null, { status: 500 }); + } + // Return the default response for all other requests that match URL and verb. (in our case get favorites) + return HttpResponse.json(DATA); + }), + ); +}; + +describe('PhoneBook', () => { +... + it('fails to fetch all contacts and renders error message', async () => { + mockServerFailureForGetAllContacts(); + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + expect( + await screen.findByText(/an error occurred: error fetching contacts/i), + ).toBeOnTheScreen(); + }); +}); + +```` + +## Global guarding against unwanted API requests + +As mistakes may happen, we might forget to mock a network request in one of our tests in the future. +To prevent us from happening, and alert when a certain network request is left unhandled, you may choose to +move MSW's server management from `PhoneBook.test.tsx` to Jest's setup file via [`setupFilesAfterEnv`](https://jestjs.io/docs/configuration#setupfilesafterenv-array). + +```tsx title=examples/cookbook/jest-setup.ts +// Enable API mocking via Mock Service Worker (MSW) +beforeAll(() => server.listen()); +// Reset any runtime request handlers we may add during the tests +afterEach(() => server.resetHandlers()); +// Disable API mocking after the tests are done +afterAll(() => server.close()); + +// ... rest of your setup file +``` + +This setup will ensure you have the MSW server running before any test suite starts and stops it after all tests are done. +Which will result in a warning in the console if you forget to mock an API request in your test suite. + +```bash +[MSW] Warning: intercepted a request without a matching request handler: + • GET https://randomuser.me/api/?results=25?results=25 +``` + +## Conclusion + +Testing a component that makes network requests in combination with MSW takes some initial preparation to configure and describe the overridden networks. +We can achieve that by using MSW's request handlers and intercepting APIs. + +Once up and running we gain full grip over the network requests, their responses, statuses. +Doing so is crucial to be able to test how our application behaves in different +scenarios, such as when the request is successful or when it fails. + +When global configuration is in place, MSW's will also warn us when an unhandled network requests has occurred throughout a test suite. + +## Further Reading and Alternatives + +Explore more advanced scenarios for mocking network requests with MSW: + +- MSW's Basics - [Intercepting requests](https://mswjs.io/docs/basics/intercepting-requests) and/or [Mocking responses](https://mswjs.io/docs/basics/mocking-responses) +- MSW's Network behavior - how to describe [REST](https://mswjs.io/docs/network-behavior/rest) and/or [GraphQL](https://mswjs.io/docs/network-behavior/graphql) APIs diff --git a/website/docs/12.x/cookbook/state-management/jotai.md b/website/docs/12.x/cookbook/state-management/jotai.md index 90207422..8471367c 100644 --- a/website/docs/12.x/cookbook/state-management/jotai.md +++ b/website/docs/12.x/cookbook/state-management/jotai.md @@ -12,7 +12,7 @@ the developer experience. Let's assume we have a simple task list component that uses Jotai for state management. The component has a list of tasks, a text input for typing new task name and a button to add a new task to the list. -```tsx title=jotai/index.test.tsx +```tsx title=state-management/jotai/TaskList.tsx import * as React from 'react'; import { Pressable, Text, TextInput, View } from 'react-native'; import { useAtom } from 'jotai'; @@ -65,7 +65,7 @@ We can test our `TaskList` component using React Native Testing Library's (RNTL) function. Although it is sufficient to test the empty state of the `TaskList` component, it is not enough to test the component with initial tasks present in the list. -```tsx title=jotai/index.test.tsx +```tsx title=status-management/jotai/__tests__/TaskList.test.tsx import * as React from 'react'; import { render, screen, userEvent } from '@testing-library/react-native'; import { renderWithAtoms } from './test-utils'; @@ -88,7 +88,7 @@ initial values. We can create a custom render function that uses Jotai's `useHyd hydrate the atoms with initial values. This function will accept the initial atoms and their corresponding values as an argument. -```tsx title=test-utils.tsx +```tsx title=status-management/jotai/test-utils.tsx import * as React from 'react'; import { render } from '@testing-library/react-native'; import { useHydrateAtoms } from 'jotai/utils'; @@ -144,7 +144,7 @@ We can now use the `renderWithAtoms` function to render the `TaskList` component In our test, we populated only one atom and its initial value, but you can add other Jotai atoms and their corresponding values to the initialValues array as needed. ::: -```tsx title=jotai/index.test.tsx +```tsx title=status-management/jotai/__tests__/TaskList.test.tsx ======= const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; @@ -173,7 +173,7 @@ test('renders a to do list with 1 items initially, and adds a new item', async ( In several cases, you might need to change an atom's state outside a React component. In our case, we have a set of functions to get tasks and set tasks, which change the state of the task list atom. -```tsx title=state.ts +```tsx title=state-management/jotai/state.ts import { atom, createStore } from 'jotai'; import { Task } from './types'; @@ -201,7 +201,7 @@ the initial to-do items in the store and then checking if the functions work as No special setup is required to test these functions, as `store.set` is available by default by Jotai. -```tsx title=jotai/index.test.tsx +```tsx title=state-management/jotai/__tests__/TaskList.test.tsx import { addTask, getAllTasks, store, tasksAtom } from './state'; //...