diff --git a/examples/cookbook/__mocks__/axios.ts b/examples/cookbook/__mocks__/axios.ts new file mode 100644 index 000000000..7792f103c --- /dev/null +++ b/examples/cookbook/__mocks__/axios.ts @@ -0,0 +1,13 @@ +const chuckNorrisError = () => { + throw Error( + "Please ensure you mock 'Axios' - Only Chuck Norris is allowed to make API requests when testing ;)", + ); +}; + +export default { + get: jest.fn(chuckNorrisError), + post: jest.fn(chuckNorrisError), + put: jest.fn(chuckNorrisError), + delete: jest.fn(chuckNorrisError), + request: jest.fn(chuckNorrisError), +}; diff --git a/examples/cookbook/app/index.tsx b/examples/cookbook/app/index.tsx index 025a57d29..c6955de1f 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: 'network-requests/' }, ]; diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx new file mode 100644 index 000000000..fe25520da --- /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 000000000..5cd9b43c8 --- /dev/null +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; +import React from 'react'; +import axios from 'axios'; +import PhoneBook from '../PhoneBook'; +import { User } from '../types'; + +jest.mock('axios'); + +jest.setTimeout(10000); +describe('PhoneBook', () => { + it('fetches contacts successfully and renders in list', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(DATA), + }); + (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); + 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); + }); + + it('fails to fetch contacts and renders error message', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + }); + (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + expect(await screen.findByText(/error fetching contacts/i)).toBeOnTheScreen(); + }); + + it('fetches favorites successfully and renders all users avatars', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(DATA), + }); + (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); + expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); + }); + + it('fails to fetch favorites and renders error message', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(DATA), + }); + (axios.get as jest.Mock).mockRejectedValueOnce({ message: 'Error fetching favorites' }); + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen(); + }); +}); + +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 000000000..118f242da --- /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 000000000..a54c229fe --- /dev/null +++ b/examples/cookbook/app/network-requests/api/getAllFavorites.ts @@ -0,0 +1,7 @@ +import axios from 'axios'; +import { User } from '../types'; + +export default async (): Promise => { + const res = await axios.get('https://randomuser.me/api/?results=10'); + return res.data.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 000000000..f62f99f00 --- /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 000000000..17503200c --- /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 000000000..86075de32 --- /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 000000000..f198d644d --- /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 7f63025d9..1938288c4 100644 --- a/examples/cookbook/jest-setup.ts +++ b/examples/cookbook/jest-setup.ts @@ -5,3 +5,17 @@ import '@testing-library/react-native/extend-expect'; // Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); + +// Guard against API requests made during testing +beforeAll(() => { + // the global fetch function: + jest.spyOn(global, 'fetch').mockImplementation(()=> { + throw Error("Please ensure you mock 'fetch' Only Chuck Norris is allowed to make API requests when testing ;)"); + }); + // with Axios: + // see examples/cookbook/__mocks__/axios.ts +}); +afterAll(() => { + // restore the original fetch function + (global.fetch as jest.Mock).mockRestore(); +}); diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 64f440c33..0b9994870 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "axios": "^1.7.5", "expo": "^51.0.26", "expo-constants": "~16.0.2", "expo-linking": "~6.3.1", diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index 992200595..070fd6798 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -3348,6 +3348,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.7.5": + version: 1.7.5 + resolution: "axios@npm:1.7.5" + dependencies: + follow-redirects: "npm:^1.15.6" + form-data: "npm:^4.0.0" + proxy-from-env: "npm:^1.1.0" + checksum: 10c0/1d5daeb28b3d1bb2a7b9f0743433c4bfbeaddc15461e50ebde487eec6c009af2515749d5261096dd430c90cd891bd310bcba5ec3967bae2033c4a307f58a6ad3 + languageName: node + linkType: hard + "babel-core@npm:^7.0.0-bridge.0": version: 7.0.0-bridge.0 resolution: "babel-core@npm:7.0.0-bridge.0" @@ -5389,6 +5400,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.6": + version: 1.15.6 + resolution: "follow-redirects@npm:1.15.6" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/9ff767f0d7be6aa6870c82ac79cf0368cd73e01bbc00e9eb1c2a16fbb198ec105e3c9b6628bb98e9f3ac66fe29a957b9645bcb9a490bb7aa0d35f908b6b85071 + languageName: node + linkType: hard + "fontfaceobserver@npm:^2.1.0": version: 2.3.0 resolution: "fontfaceobserver@npm:2.3.0" @@ -5426,6 +5447,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + "freeport-async@npm:2.0.0": version: 2.0.0 resolution: "freeport-async@npm:2.0.0" @@ -8887,6 +8919,13 @@ __metadata: languageName: node linkType: hard +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: 10c0/fe7dd8b1bdbbbea18d1459107729c3e4a2243ca870d26d34c2c1bcd3e4425b7bcc5112362df2d93cc7fb9746f6142b5e272fd1cc5c86ddf8580175186f6ad42b + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.0 resolution: "pump@npm:3.0.0" @@ -9504,6 +9543,7 @@ __metadata: "@types/jest": "npm:^29.5.12" "@types/react": "npm:~18.2.45" "@types/react-native-get-random-values": "npm:^1" + axios: "npm:^1.7.5" eslint: "npm:^8.57.0" expo: "npm:^51.0.26" expo-constants: "npm:~16.0.2" diff --git a/website/docs/12.x/cookbook/_meta.json b/website/docs/12.x/cookbook/_meta.json index 2aae97071..91a92a8e4 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": "network-requests", + "label": "Network Requests Recipes" + }, { "type": "dir", "name": "state-management", diff --git a/website/docs/12.x/cookbook/network-requests/_meta.json b/website/docs/12.x/cookbook/network-requests/_meta.json new file mode 100644 index 000000000..15b6e4062 --- /dev/null +++ b/website/docs/12.x/cookbook/network-requests/_meta.json @@ -0,0 +1 @@ +["axios", "fetch"] diff --git a/website/docs/12.x/cookbook/network-requests/axios.md b/website/docs/12.x/cookbook/network-requests/axios.md new file mode 100644 index 000000000..cbf756d89 --- /dev/null +++ b/website/docs/12.x/cookbook/network-requests/axios.md @@ -0,0 +1,227 @@ +# Axios + +## Introduction + +Axios is a popular library for making HTTP requests in JavaScript. It is promise-based and has a +simple API that makes it easy to use. +In this guide, we will show you how to mock Axios requests and guard your test suits from unwanted +and unmocked API 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 Axios for fetching Data from a server. +In our case, we have a list of favorite contacts 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 {User} from './types'; +import FavoritesList from './components/FavoritesList'; + +export default () => { + const [favoritesData, setFavoritesData] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const run = async () => { + try { + const _data = await getAllFavorites(); + setFavoritesData(_data); + } catch (e) { + setError(e.message); + } + }; + + void run(); + }, []); + + if (error) { + return An error occurred: {error}; + } + + return ( + + ); +}; + +``` + +We fetch the contacts from the server using the `getAllFavorites` function that utilizes Axios. + +```tsx title=network-requests/api/getAllFavorites.ts +import axios from 'axios'; +import {User} from '../types'; + +export default async (): Promise => { + const res = await axios.get('https://randomuser.me/api/?results=10'); + return res.data.results; +}; + +``` + +Our `FavoritesList` component is a simple component that displays the list of favorite contacts and +their avatars. + +```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/network-requests/components/FavoritesList.tsx +const styles = +... +``` + +## Start testing with a simple test +In our test we will make sure we mock the `axios.get` function to return the data we want. +In this specific case, we will return a list of 3 users. + +::info +By using `mockResolvedValueOnce` we gain more grip and prevent the mock from resolving the data +multiple times, which might lead to unexpected behavior. +::: + +```tsx title=network-requests/Phonebook.test.tsx +import {render, waitForElementToBeRemoved} from '@testing-library/react-native'; +import React from 'react'; +import PhoneBook from '../PhoneBook'; +import {User} from '../types'; +import axios from 'axios'; + +jest.mock('axios'); + +describe('PhoneBook', () => { + it('fetches favorites successfully and renders all users avatars', async () => { + // Mock the axios.get function to return the data we want + (axios.get as jest.Mock).mockResolvedValueOnce({data: DATA}); + render(); + + // Wait for the loader to disappear + await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); + // All the avatars should be rendered + 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__/PhoneBook.test.tsx + ... + ], +}; + +``` + +## Testing error handling +As we are dealing with network requests, we should also test how our application behaves when the API +request fails. We can mock the `axios.get` function to throw an error and test if our application is +handling the error correctly. + +:::note +It is good to note that Axios throws auto. an error when the response status code is not in the range of 2xx. +::: + +```tsx title=network-requests/Phonebook.test.tsx +... +it('fails to fetch favorites and renders error message', async () => { + // Mock the axios.get function to throw an error + (axios.get as jest.Mock).mockRejectedValueOnce({ message: 'Error fetching favorites' }); + render(); + + // Wait for the loader to disappear + await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + // Error message should be displayed + expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen(); +}); +```` + +## Global guarding against unwanted API requests + +As mistakes may happen, we might forget to mock an API request in one of our tests in the future. +To prevent we make unwanted API requests, and alert the developer when it happens, we can globally +mock the `axios` module in our test suite. + +```tsx title=__mocks__/axios.ts +const chuckNorrisError = () => { + throw Error( + "Please ensure you mock 'Axios' - Only Chuck Norris is allowed to make API requests when testing ;)", + ); +}; + +export default { + // Mock all the methods to throw an error + get: jest.fn(chuckNorrisError), + post: jest.fn(chuckNorrisError), + put: jest.fn(chuckNorrisError), + delete: jest.fn(chuckNorrisError), + request: jest.fn(chuckNorrisError), +}; +``` + +## Conclusion +Testing a component that makes network requests with Axios is straightforward. By mocking the Axios +requests, we can control the data that is returned and test how our application behaves in different +scenarios, such as when the request is successful or when it fails. +There are many ways to mock Axios requests, and the method you choose will depend on your specific +use case. In this guide, we showed you how to mock Axios requests using Jest's `jest.mock` function +and how to guard against unwanted API requests throughout your test suite. diff --git a/website/docs/12.x/cookbook/network-requests/fetch.md b/website/docs/12.x/cookbook/network-requests/fetch.md new file mode 100644 index 000000000..690561d1e --- /dev/null +++ b/website/docs/12.x/cookbook/network-requests/fetch.md @@ -0,0 +1,241 @@ +# Fetch + +## Introduction + +React Native provides the Fetch API for your networking needs. It is promise-based and provides a +simple and clean API for making requests. In this guide, we will show you how to mock `fetch` requests +and guard your test suits from unwanted and unmocked API 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` for fetching Data from a server. +In our case, we have a list of contacts 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 {User} from './types'; +import FavoritesList from './components/FavoritesList'; + +export default () => { + const [usersData, setUsersData] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const run = async () => { + try { + const _data = await getAllContacts(); + setUsersData(_data); + } catch (e) { + setError(e.message); + } + }; + + void run(); + }, []); + + if (error) { + return An error occurred: {error}; + } + + return ( + + ); +}; + +``` + +We fetch the contacts from the server using the `getAllContacts` 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; +}; +``` + +Our `ContactsList` component is a simple component that displays the list of favorite contacts and +their avatars. + +```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 ( + + Users data not quite there yet... + + ); + + return ( + + + data={users} + renderItem={renderItem} + keyExtractor={(item, index) => `${index}-${item.id.value}`} + /> + + ); +}; + +// Looking for styles? +// Check examples/cookbook/app/network-requests/components/ContactsList.tsx +const styles = +... +``` + +## Start testing with a simple test + +In our test we will make sure we mock the `fetch` function to return the data we want. +In this specific case, we will return a list of 3 users. +As the `fetch` api is available globally, we can mock it by using `jest.spyOn` specifically on the +`global` object. + +:::info +By using `mockResolvedValueOnce` we gain more grip and prevent the mock from resolving the data +multiple times, which might lead to unexpected behavior. +::: + +```tsx title=network-requests/Phonebook.test.tsx +import {render, waitForElementToBeRemoved} from '@testing-library/react-native'; +import React from 'react'; +import PhoneBook from '../PhoneBook'; +import {User} from '../types'; + +describe('PhoneBook', () => { + it('fetches contacts successfully and renders in list', async () => { + // mock the fetch function to return the data we want + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce(DATA), + }); + render(); + + // Wait for the loader to disappear + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + // Check if the users are displayed + 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); + }); +}); + +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__/PhoneBook.test.tsx + ... + ], +}; + +``` + +## Testing error handling + +As we are dealing with network requests, we should also test how our application behaves when the +API request fails. We can mock the `fetch` function to throw an error and/or mark it's response as +not 'ok' in order to verify if our application is handling the error correctly. + +:::note +The `fetch` function will reject the promise on some errors, but not if the server responds +with an error status like 404: so we also check the response status and throw if it is not OK. +See MDN's [docs](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) for more +::: + +```tsx title=network-requests/Phonebook.test.tsx +... +it('fails to fetch contacts and renders error message', async () => { + // mock the fetch function to be not ok which will throw an error + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + }); + render(); + + // Wait for the loader to disappear + await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + // Check if the error message is displayed + expect(await screen.findByText(/error fetching contacts/i)).toBeOnTheScreen(); +}); +```` + +## Global guarding against unwanted API requests + +As mistakes may happen, we might forget to mock an API request in one of our tests in the future. +To prevent we make unwanted API requests, and alert the developer when it happens, we can globally +mock the `fetch` in our test suite via the `jest.setup.ts` file. Ensure this setup file is included +in [`setupFilesAfterEnv`](https://jestjs.io/docs/configuration#setupfilesafterenv-array) in your Jest configuration. + +```tsx title=jest.setup.ts +beforeAll(() => { + // the global fetch function: + jest.spyOn(global, 'fetch').mockImplementation(() => { + throw Error("Please ensure you mock 'fetch' Only Chuck Norris is allowed to make API requests when testing ;)"); + }); +}); +afterAll(() => { + // restore the original fetch function + (global.fetch as jest.Mock).mockRestore(); +}); + +``` + +## Conclusion + +Testing a component that makes network requests with `fetch` is straightforward. By mocking the fetch +requests, we can control the data that is returned and test how our application behaves in different +scenarios, such as when the request is successful or when it fails. +There are many ways to mock `fetch` requests, and the method you choose will depend on your specific +use case. In this guide, we showed you how to mock `fetch` requests using Jest's `jest.spyOn` function +and how to guard against unwanted API requests throughout your test suite. diff --git a/website/docs/12.x/cookbook/state-management/jotai.md b/website/docs/12.x/cookbook/state-management/jotai.md index 902074226..8471367c0 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'; //...