From 19dd3bc083b4b527e60075aab4b0a29ceba2ab75 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Wed, 28 Aug 2024 07:29:43 +0200 Subject: [PATCH 01/12] squash prev commits --- examples/cookbook/__mocks__/axios.ts | 13 + examples/cookbook/app/index.tsx | 5 +- .../app/network-requests/PhoneBook.tsx | 52 ++++ .../__tests__/PhoneBook.test.tsx | 119 +++++++++ .../network-requests/api/getAllContacts.ts | 10 + .../network-requests/api/getAllFavorites.ts | 7 + .../components/ContactsList.tsx | 60 +++++ .../components/FavoritesList.tsx | 59 +++++ .../cookbook/app/network-requests/index.tsx | 6 + .../cookbook/app/network-requests/types.ts | 18 ++ examples/cookbook/jest-setup.ts | 14 + examples/cookbook/package.json | 1 + examples/cookbook/yarn.lock | 40 +++ website/docs/12.x/cookbook/_meta.json | 5 + .../12.x/cookbook/network-requests/_meta.json | 1 + .../12.x/cookbook/network-requests/axios.md | 227 +++++++++++++++++ .../12.x/cookbook/network-requests/fetch.md | 241 ++++++++++++++++++ .../12.x/cookbook/state-management/jotai.md | 12 +- 18 files changed, 882 insertions(+), 8 deletions(-) create mode 100644 examples/cookbook/__mocks__/axios.ts create mode 100644 examples/cookbook/app/network-requests/PhoneBook.tsx create mode 100644 examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx create mode 100644 examples/cookbook/app/network-requests/api/getAllContacts.ts create mode 100644 examples/cookbook/app/network-requests/api/getAllFavorites.ts create mode 100644 examples/cookbook/app/network-requests/components/ContactsList.tsx create mode 100644 examples/cookbook/app/network-requests/components/FavoritesList.tsx create mode 100644 examples/cookbook/app/network-requests/index.tsx create mode 100644 examples/cookbook/app/network-requests/types.ts create mode 100644 website/docs/12.x/cookbook/network-requests/_meta.json create mode 100644 website/docs/12.x/cookbook/network-requests/axios.md create mode 100644 website/docs/12.x/cookbook/network-requests/fetch.md 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'; //... From 35a9e9e131facf1d8a5a624592d3f781ff433828 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Wed, 28 Aug 2024 07:36:21 +0200 Subject: [PATCH 02/12] remove unneeded axios mock --- .../cookbook/app/network-requests/__tests__/PhoneBook.test.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 5cd9b43c8..685317655 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -4,9 +4,6 @@ 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({ From 81e74627b155f92f54bbfb153e57e70651dd4ffc Mon Sep 17 00:00:00 2001 From: stevegalili Date: Wed, 28 Aug 2024 07:42:03 +0200 Subject: [PATCH 03/12] set maxWorkers=2 --- examples/cookbook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 0b9994870..e7fd1c5f5 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -6,7 +6,7 @@ "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject", - "test": "jest", + "test": "jest --maxWorkers=2", "lint": "eslint .", "typecheck": "tsc --noEmit" }, From 2c33c443afdf8f686945db28e0561c06bb0203f8 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Wed, 28 Aug 2024 07:46:17 +0200 Subject: [PATCH 04/12] run with a slow test reporter --- .../app/network-requests/__tests__/PhoneBook.test.tsx | 2 ++ examples/cookbook/jest.config.js | 1 + examples/cookbook/package.json | 1 + examples/cookbook/yarn.lock | 8 ++++++++ 4 files changed, 12 insertions(+) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 685317655..7a0bdb59f 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -4,6 +4,8 @@ import axios from 'axios'; import PhoneBook from '../PhoneBook'; import { User } from '../types'; +jest.setTimeout(10000); + describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ diff --git a/examples/cookbook/jest.config.js b/examples/cookbook/jest.config.js index 839f9c880..9e799c68e 100644 --- a/examples/cookbook/jest.config.js +++ b/examples/cookbook/jest.config.js @@ -3,4 +3,5 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], setupFilesAfterEnv: ['./jest-setup.ts'], testMatch: ['**/*.test.{ts,tsx}'], + reporters: [['jest-slow-test-reporter', { numTests: 8, warnOnSlowerThan: 300, color: true }]], }; diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index e7fd1c5f5..49aa0420b 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -38,6 +38,7 @@ "@types/react-native-get-random-values": "^1", "eslint": "^8.57.0", "jest": "^29.7.0", + "jest-slow-test-reporter": "^1.0.0", "react-test-renderer": "18.2.0", "typescript": "~5.3.3" }, diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index 070fd6798..8b010eb4e 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -6937,6 +6937,13 @@ __metadata: languageName: node linkType: hard +"jest-slow-test-reporter@npm:^1.0.0": + version: 1.0.0 + resolution: "jest-slow-test-reporter@npm:1.0.0" + checksum: 10c0/e613596602b0bfa9c1dea651d54cbeaa203136287c8e5949264ffab630dc16548bcbe799087c8212fe72a08f48b3737e596cbea5c1a3caf068517f3be5dcc051 + languageName: node + linkType: hard + "jest-snapshot@npm:^29.7.0": version: 29.7.0 resolution: "jest-snapshot@npm:29.7.0" @@ -9552,6 +9559,7 @@ __metadata: expo-splash-screen: "npm:~0.27.5" expo-status-bar: "npm:~1.12.1" jest: "npm:^29.7.0" + jest-slow-test-reporter: "npm:^1.0.0" jotai: "npm:^2.8.4" nanoid: "npm:^3.3.7" react: "npm:18.2.0" From 5a5cab24abb50cdc285e6514ebc129a41601fcb7 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Wed, 28 Aug 2024 07:54:51 +0200 Subject: [PATCH 05/12] revert: run with a slow test reporter --- examples/cookbook/jest.config.js | 1 - examples/cookbook/package.json | 3 +-- examples/cookbook/yarn.lock | 8 -------- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/examples/cookbook/jest.config.js b/examples/cookbook/jest.config.js index 9e799c68e..839f9c880 100644 --- a/examples/cookbook/jest.config.js +++ b/examples/cookbook/jest.config.js @@ -3,5 +3,4 @@ module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], setupFilesAfterEnv: ['./jest-setup.ts'], testMatch: ['**/*.test.{ts,tsx}'], - reporters: [['jest-slow-test-reporter', { numTests: 8, warnOnSlowerThan: 300, color: true }]], }; diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 49aa0420b..0b9994870 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -6,7 +6,7 @@ "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject", - "test": "jest --maxWorkers=2", + "test": "jest", "lint": "eslint .", "typecheck": "tsc --noEmit" }, @@ -38,7 +38,6 @@ "@types/react-native-get-random-values": "^1", "eslint": "^8.57.0", "jest": "^29.7.0", - "jest-slow-test-reporter": "^1.0.0", "react-test-renderer": "18.2.0", "typescript": "~5.3.3" }, diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index 8b010eb4e..070fd6798 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -6937,13 +6937,6 @@ __metadata: languageName: node linkType: hard -"jest-slow-test-reporter@npm:^1.0.0": - version: 1.0.0 - resolution: "jest-slow-test-reporter@npm:1.0.0" - checksum: 10c0/e613596602b0bfa9c1dea651d54cbeaa203136287c8e5949264ffab630dc16548bcbe799087c8212fe72a08f48b3737e596cbea5c1a3caf068517f3be5dcc051 - languageName: node - linkType: hard - "jest-snapshot@npm:^29.7.0": version: 29.7.0 resolution: "jest-snapshot@npm:29.7.0" @@ -9559,7 +9552,6 @@ __metadata: expo-splash-screen: "npm:~0.27.5" expo-status-bar: "npm:~1.12.1" jest: "npm:^29.7.0" - jest-slow-test-reporter: "npm:^1.0.0" jotai: "npm:^2.8.4" nanoid: "npm:^3.3.7" react: "npm:18.2.0" From 4a2fb86294cd1b2c5c9b7d47e5b1744221a458d2 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Fri, 30 Aug 2024 08:28:04 +0200 Subject: [PATCH 06/12] Add url check to mock and further reading and alternatives --- examples/cookbook/__mocks__/axios.ts | 1 + .../__tests__/PhoneBook.test.tsx | 94 +++------------ .../network-requests/__tests__/test-utils.ts | 113 ++++++++++++++++++ .../12.x/cookbook/network-requests/axios.md | 41 ++++++- .../12.x/cookbook/network-requests/fetch.md | 51 ++++++-- 5 files changed, 205 insertions(+), 95 deletions(-) create mode 100644 examples/cookbook/app/network-requests/__tests__/test-utils.ts diff --git a/examples/cookbook/__mocks__/axios.ts b/examples/cookbook/__mocks__/axios.ts index 7792f103c..aad3b65bc 100644 --- a/examples/cookbook/__mocks__/axios.ts +++ b/examples/cookbook/__mocks__/axios.ts @@ -5,6 +5,7 @@ const chuckNorrisError = () => { }; export default { + ...jest.requireActual('axios'), get: jest.fn(chuckNorrisError), post: jest.fn(chuckNorrisError), put: jest.fn(chuckNorrisError), diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 7a0bdb59f..999e6022f 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -1,18 +1,19 @@ 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'; +import { + mockAxiosGetWithFailureResponse, + mockAxiosGetWithSuccessResponse, + mockFetchWithFailureResponse, + mockFetchWithSuccessResponse, +} from './test-utils'; 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 }); + mockFetchWithSuccessResponse(); + mockAxiosGetWithSuccessResponse(); render(); await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); @@ -22,10 +23,8 @@ describe('PhoneBook', () => { }); 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 }); + mockFetchWithFailureResponse(); + mockAxiosGetWithSuccessResponse(); render(); await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); @@ -33,11 +32,8 @@ describe('PhoneBook', () => { }); 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 }); + mockFetchWithSuccessResponse(); + mockAxiosGetWithSuccessResponse(); render(); await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); @@ -46,73 +42,11 @@ describe('PhoneBook', () => { }); 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' }); + mockFetchWithSuccessResponse(); + mockAxiosGetWithFailureResponse(); 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/__tests__/test-utils.ts b/examples/cookbook/app/network-requests/__tests__/test-utils.ts new file mode 100644 index 000000000..175151de5 --- /dev/null +++ b/examples/cookbook/app/network-requests/__tests__/test-utils.ts @@ -0,0 +1,113 @@ +import { User } from '../types'; +import axios from 'axios'; + +class MismatchedUrlError extends Error { + constructor(url: string) { + super(`The URL: ${url} does not match the API's base URL.`); + } +} + +/** + * Ensures that the URL matches the base URL of the API. + * @param url + * @throws {MismatchedUrlError} + */ +const ensureUrlMatchesBaseUrl = (url: string) => { + if (!url.includes('https://randomuser.me/api')) throw new MismatchedUrlError(url); +}; + +export const mockFetchWithSuccessResponse = () => { + (global.fetch as jest.Mock).mockImplementationOnce((url) => { + ensureUrlMatchesBaseUrl(url); + + return Promise.resolve({ + ok: true, + json: jest.fn().mockResolvedValueOnce(DATA), + }); + }); +}; + +export const mockFetchWithFailureResponse = () => { + (global.fetch as jest.Mock).mockImplementationOnce((url) => { + ensureUrlMatchesBaseUrl(url); + + return Promise.resolve({ + ok: false, + }); + }); +}; + +export const mockAxiosGetWithSuccessResponse = () => { + (axios.get as jest.Mock).mockImplementationOnce((url) => { + ensureUrlMatchesBaseUrl(url); + + return Promise.resolve({ data: DATA }); + }); +}; + +export const mockAxiosGetWithFailureResponse = () => { + (axios.get as jest.Mock).mockImplementationOnce((url) => { + ensureUrlMatchesBaseUrl(url); + + return Promise.reject({ message: 'Error fetching favorites' }); + }); +}; + +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/website/docs/12.x/cookbook/network-requests/axios.md b/website/docs/12.x/cookbook/network-requests/axios.md index cbf756d89..29a22a76c 100644 --- a/website/docs/12.x/cookbook/network-requests/axios.md +++ b/website/docs/12.x/cookbook/network-requests/axios.md @@ -116,9 +116,11 @@ const styles = 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. +:::info +To prevent unexpected behavior, we ensure the following: +- Prevent the mock from resolving data multiple times by using `mockResolvedValueOnce`. +- Ensure the URL matches the base URL of the API by using a custom function `ensureUrlMatchesBaseUrl`. + ::: ```tsx title=network-requests/Phonebook.test.tsx @@ -127,13 +129,25 @@ import React from 'react'; import PhoneBook from '../PhoneBook'; import {User} from '../types'; import axios from 'axios'; +import {MismatchedUrlError} from './test-utils'; + +const ensureUrlMatchesBaseUrl = (url: string) => { + if (!url.includes('https://randomuser.me/api')) throw new MismatchedUrlError(url); +}; -jest.mock('axios'); +export const mockAxiosGetWithSuccessResponse = () => { + (axios.get as jest.Mock).mockImplementationOnce((url) => { + // Ensure the URL matches the base URL of the API + ensureUrlMatchesBaseUrl(url); + + return Promise.resolve({ data: DATA }); + }); +}; 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}); + mockAxiosGetWithSuccessResponse(); render(); // Wait for the loader to disappear @@ -183,9 +197,18 @@ It is good to note that Axios throws auto. an error when the response status cod ```tsx title=network-requests/Phonebook.test.tsx ... + +export const mockAxiosGetWithFailureResponse = () => { + (axios.get as jest.Mock).mockImplementationOnce((url) => { + ensureUrlMatchesBaseUrl(url); + + return Promise.reject({ message: 'Error fetching favorites' }); + }); +}; + 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' }); + mockAxiosGetWithFailureResponse(); render(); // Wait for the loader to disappear @@ -225,3 +248,9 @@ 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. + +## Further Reading and Alternatives + +Explore more powerful tools for mocking network requests in your React Native application: +- [Axios Mock Adapter](https://github.com/ctimmerm/axios-mock-adapter): A popular library for mocking Axios calls with an extensive API, making it easy to simulate various scenarios. +- [MSW (Mock Service Worker)](https://mswjs.io/): Great for spinning up a local test server that intercepts network requests at the network level, providing end-to-end testing capabilities. diff --git a/website/docs/12.x/cookbook/network-requests/fetch.md b/website/docs/12.x/cookbook/network-requests/fetch.md index 690561d1e..2e405d5a3 100644 --- a/website/docs/12.x/cookbook/network-requests/fetch.md +++ b/website/docs/12.x/cookbook/network-requests/fetch.md @@ -127,22 +127,40 @@ As the `fetch` api is available globally, we can mock it by using `jest.spyOn` s `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. +To prevent unexpected behavior, we ensure the following: +- Prevent the mock from resolving data multiple times by using `mockResolvedValueOnce`. +- Ensure the URL matches the base URL of the API by using a custom function `ensureUrlMatchesBaseUrl`. + ::: + ```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 {MismatchedUrlError} from './test-utils'; + +const ensureUrlMatchesBaseUrl = (url: string) => { + if (!url.includes('https://randomuser.me/api')) throw new MismatchedUrlError(url); +}; + +export const mockFetchWithSuccessResponse = () => { + (global.fetch as jest.Mock).mockImplementationOnce((url) => { + // Ensure the URL matches the base URL of the API + ensureUrlMatchesBaseUrl(url); + + return Promise.resolve({ + ok: true, + json: jest.fn().mockResolvedValueOnce(DATA), + }); + }); +}; 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), - }); + mockFetchWithSuccessResponse(); render(); // Wait for the loader to disappear @@ -196,11 +214,20 @@ See MDN's [docs](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Usin ```tsx title=network-requests/Phonebook.test.tsx ... + +export const mockFetchWithFailureResponse = () => { + (global.fetch as jest.Mock).mockImplementationOnce((url) => { + ensureUrlMatchesBaseUrl(url); + + return Promise.resolve({ + ok: false, + }); + }); +}; + 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, - }); + mockFetchWithFailureResponse(); render(); // Wait for the loader to disappear @@ -236,6 +263,12 @@ afterAll(() => { 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 +There are many ways to mock `fetch` requests, and the method you choose will always 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. + +## Further Reading and Alternatives + +Explore more powerful tools for mocking network requests in your React Native application: +- [Fetch Mock](https://www.wheresrhys.co.uk/fetch-mock/): A popular library for mocking fetch calls with an extensive API, making it easy to simulate various scenarios. +- [MSW (Mock Service Worker)](https://mswjs.io/): Great for spinning up a local test server that intercepts network requests at the network level, providing end-to-end testing capabilities. From 336c00e1f7f2805d2890583cceab5b7565774033 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Mon, 9 Sep 2024 09:11:47 +0200 Subject: [PATCH 07/12] use MSW for all API calls in cookbook test suits --- examples/cookbook/__mocks__/axios.ts | 14 - .../__tests__/PhoneBook.test.tsx | 30 +- .../network-requests/__tests__/test-utils.ts | 86 ++--- .../network-requests/api/getAllFavorites.ts | 9 +- examples/cookbook/jest-setup.ts | 17 +- examples/cookbook/package.json | 2 +- examples/cookbook/yarn.lock | 361 ++++++++++++++++-- 7 files changed, 379 insertions(+), 140 deletions(-) delete mode 100644 examples/cookbook/__mocks__/axios.ts diff --git a/examples/cookbook/__mocks__/axios.ts b/examples/cookbook/__mocks__/axios.ts deleted file mode 100644 index aad3b65bc..000000000 --- a/examples/cookbook/__mocks__/axios.ts +++ /dev/null @@ -1,14 +0,0 @@ -const chuckNorrisError = () => { - throw Error( - "Please ensure you mock 'Axios' - Only Chuck Norris is allowed to make API requests when testing ;)", - ); -}; - -export default { - ...jest.requireActual('axios'), - 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/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 999e6022f..39bbb94c9 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -2,48 +2,34 @@ import { render, screen, waitForElementToBeRemoved } from '@testing-library/reac import React from 'react'; import PhoneBook from '../PhoneBook'; import { - mockAxiosGetWithFailureResponse, - mockAxiosGetWithSuccessResponse, - mockFetchWithFailureResponse, - mockFetchWithSuccessResponse, + mockServerFailureForGetAllContacts, + mockServerFailureForGetAllFavorites, } from './test-utils'; jest.setTimeout(10000); describe('PhoneBook', () => { - it('fetches contacts successfully and renders in list', async () => { - mockFetchWithSuccessResponse(); - mockAxiosGetWithSuccessResponse(); + 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 contacts and renders error message', async () => { - mockFetchWithFailureResponse(); - mockAxiosGetWithSuccessResponse(); + 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(/error fetching contacts/i)).toBeOnTheScreen(); }); - it('fetches favorites successfully and renders all users avatars', async () => { - mockFetchWithSuccessResponse(); - mockAxiosGetWithSuccessResponse(); - 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 () => { - mockFetchWithSuccessResponse(); - mockAxiosGetWithFailureResponse(); + mockServerFailureForGetAllFavorites(); render(); await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); diff --git a/examples/cookbook/app/network-requests/__tests__/test-utils.ts b/examples/cookbook/app/network-requests/__tests__/test-utils.ts index 175151de5..5f2b17372 100644 --- a/examples/cookbook/app/network-requests/__tests__/test-utils.ts +++ b/examples/cookbook/app/network-requests/__tests__/test-utils.ts @@ -1,58 +1,52 @@ import { User } from '../types'; -import axios from 'axios'; +import {http, HttpResponse} from "msw"; +import {setupServer} from "msw/node"; -class MismatchedUrlError extends Error { - constructor(url: string) { - super(`The URL: ${url} does not match the API's base URL.`); - } -} +const handlers = [ + http.get('https://randomuser.me/api/*', () => { + return HttpResponse.json(DATA); + }), +]; -/** - * Ensures that the URL matches the base URL of the API. - * @param url - * @throws {MismatchedUrlError} - */ -const ensureUrlMatchesBaseUrl = (url: string) => { - if (!url.includes('https://randomuser.me/api')) throw new MismatchedUrlError(url); -}; - -export const mockFetchWithSuccessResponse = () => { - (global.fetch as jest.Mock).mockImplementationOnce((url) => { - ensureUrlMatchesBaseUrl(url); - - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValueOnce(DATA), - }); - }); -}; +export const server = setupServer(...handlers); -export const mockFetchWithFailureResponse = () => { - (global.fetch as jest.Mock).mockImplementationOnce((url) => { - ensureUrlMatchesBaseUrl(url); +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 Promise.resolve({ - ok: false, - }); - }); + return HttpResponse.json(DATA); + }), + ); }; -export const mockAxiosGetWithSuccessResponse = () => { - (axios.get as jest.Mock).mockImplementationOnce((url) => { - ensureUrlMatchesBaseUrl(url); +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 Promise.resolve({ data: DATA }); - }); + return HttpResponse.json(DATA); + }), + ); }; - -export const mockAxiosGetWithFailureResponse = () => { - (axios.get as jest.Mock).mockImplementationOnce((url) => { - ensureUrlMatchesBaseUrl(url); - - return Promise.reject({ message: 'Error fetching favorites' }); - }); -}; - export const DATA: { results: User[] } = { results: [ { diff --git a/examples/cookbook/app/network-requests/api/getAllFavorites.ts b/examples/cookbook/app/network-requests/api/getAllFavorites.ts index a54c229fe..954d11dc5 100644 --- a/examples/cookbook/app/network-requests/api/getAllFavorites.ts +++ b/examples/cookbook/app/network-requests/api/getAllFavorites.ts @@ -1,7 +1,10 @@ -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; + 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/jest-setup.ts b/examples/cookbook/jest-setup.ts index 1938288c4..de858942e 100644 --- a/examples/cookbook/jest-setup.ts +++ b/examples/cookbook/jest-setup.ts @@ -2,20 +2,11 @@ // 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'); -// 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(); -}); +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 0b9994870..45f405252 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -11,7 +11,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "axios": "^1.7.5", "expo": "^51.0.26", "expo-constants": "~16.0.2", "expo-linking": "~6.3.1", @@ -38,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 070fd6798..175f515c9 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" @@ -3348,17 +3500,6 @@ __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" @@ -3883,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" @@ -4125,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" @@ -5400,16 +5555,6 @@ __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" @@ -5447,17 +5592,6 @@ __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" @@ -5795,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" @@ -5857,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" @@ -6348,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" @@ -8137,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" @@ -8525,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" @@ -8709,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" @@ -8919,10 +9127,10 @@ __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 +"psl@npm:^1.1.33": + version: 1.9.0 + resolution: "psl@npm:1.9.0" + checksum: 10c0/6a3f805fdab9442f44de4ba23880c4eba26b20c8e8e0830eff1cb31007f6825dace61d17203c58bfe36946842140c97a1ba7f67bc63ca2d88a7ee052b65d97ab languageName: node linkType: hard @@ -8978,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" @@ -9395,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" @@ -9543,7 +9765,6 @@ __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" @@ -9553,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" @@ -9861,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 @@ -10060,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 @@ -10088,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" @@ -10538,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" @@ -10628,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" @@ -10747,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" @@ -10828,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" @@ -10879,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" @@ -11369,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: @@ -11390,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 From f253045cfc1b6708692ed2c61f0d52a86c2bc1fd Mon Sep 17 00:00:00 2001 From: stevegalili Date: Mon, 9 Sep 2024 09:16:42 +0200 Subject: [PATCH 08/12] Comments with implem. explanation --- examples/cookbook/app/network-requests/__tests__/test-utils.ts | 2 ++ examples/cookbook/jest-setup.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/examples/cookbook/app/network-requests/__tests__/test-utils.ts b/examples/cookbook/app/network-requests/__tests__/test-utils.ts index 5f2b17372..c14edd6b5 100644 --- a/examples/cookbook/app/network-requests/__tests__/test-utils.ts +++ b/examples/cookbook/app/network-requests/__tests__/test-utils.ts @@ -2,6 +2,8 @@ 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); diff --git a/examples/cookbook/jest-setup.ts b/examples/cookbook/jest-setup.ts index de858942e..d51605250 100644 --- a/examples/cookbook/jest-setup.ts +++ b/examples/cookbook/jest-setup.ts @@ -7,6 +7,9 @@ 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()); From ba36a0f7abb66da29ff728111dc58595da793dbd Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 10 Sep 2024 10:25:08 +0200 Subject: [PATCH 09/12] Arrange docs initially to reflect new scenario and remove jest.setTimeout --- examples/cookbook/app/index.tsx | 2 +- .../__tests__/PhoneBook.test.tsx | 2 - website/docs/12.x/cookbook/_meta.json | 4 +- .../docs/12.x/cookbook/advanced/_meta.json | 1 + .../axios.md => advanced/network-requests.md} | 163 +++++++++-- .../12.x/cookbook/network-requests/_meta.json | 1 - .../12.x/cookbook/network-requests/fetch.md | 274 ------------------ 7 files changed, 135 insertions(+), 312 deletions(-) create mode 100644 website/docs/12.x/cookbook/advanced/_meta.json rename website/docs/12.x/cookbook/{network-requests/axios.md => advanced/network-requests.md} (56%) delete mode 100644 website/docs/12.x/cookbook/network-requests/_meta.json delete mode 100644 website/docs/12.x/cookbook/network-requests/fetch.md diff --git a/examples/cookbook/app/index.tsx b/examples/cookbook/app/index.tsx index c6955de1f..06564b31a 100644 --- a/examples/cookbook/app/index.tsx +++ b/examples/cookbook/app/index.tsx @@ -84,5 +84,5 @@ type Recipe = { const recipes: Recipe[] = [ { 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/' }, + { id: 3, title: 'Phone book with\na Variety of Net. Req. Methods', path: 'advanced/' }, ]; diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 39bbb94c9..d61e298d1 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,8 +6,6 @@ import { mockServerFailureForGetAllFavorites, } from './test-utils'; -jest.setTimeout(10000); - describe('PhoneBook', () => { it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { render(); diff --git a/website/docs/12.x/cookbook/_meta.json b/website/docs/12.x/cookbook/_meta.json index 91a92a8e4..deb5689d7 100644 --- a/website/docs/12.x/cookbook/_meta.json +++ b/website/docs/12.x/cookbook/_meta.json @@ -7,8 +7,8 @@ }, { "type": "dir", - "name": "network-requests", - "label": "Network Requests Recipes" + "name": "advanced", + "label": "Advanced Recipes" }, { "type": "dir", 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 000000000..9d0399cc6 --- /dev/null +++ b/website/docs/12.x/cookbook/advanced/_meta.json @@ -0,0 +1 @@ +["network-requests"] diff --git a/website/docs/12.x/cookbook/network-requests/axios.md b/website/docs/12.x/cookbook/advanced/network-requests.md similarity index 56% rename from website/docs/12.x/cookbook/network-requests/axios.md rename to website/docs/12.x/cookbook/advanced/network-requests.md index 29a22a76c..6a609a946 100644 --- a/website/docs/12.x/cookbook/network-requests/axios.md +++ b/website/docs/12.x/cookbook/advanced/network-requests.md @@ -1,39 +1,58 @@ -# Axios +# Network Requests ## 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. +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. +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. +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 {User} from './types'; +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 { - const _data = await getAllFavorites(); - setFavoritesData(_data); + await Promise.all([_getAllContacts(), _getAllFavorites()]); } catch (e) { - setError(e.message); + const message = isErrorWithMessage(e) ? e.message : 'Something went wrong'; + setError(message); } }; @@ -45,27 +64,46 @@ export default () => { } 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 fetch the contacts from the server using the `getAllFavorites` function that utilizes Axios. +We do the same for fetching the favorites, but this time limiting the results to 10. ```tsx title=network-requests/api/getAllFavorites.ts -import axios from 'axios'; -import {User} from '../types'; +import { User } from '../types'; export default async (): Promise => { - const res = await axios.get('https://randomuser.me/api/?results=10'); - return res.data.results; + 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. +their avatars horizontally. ```tsx title=network-requests/components/FavoritesList.tsx import {FlatList, Image, StyleSheet, Text, View} from 'react-native'; @@ -107,19 +145,73 @@ export default ({users}: { users: User[] }) => { }; // Looking for styles? -// Check examples/cookbook/app/network-requests/components/FavoritesList.tsx +// 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 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. + +In our test we would like to test if the `PhoneBook` component renders the `FavoritesList` +and `ContactsList` components correctly. +We will mock the network requests and their responses to ensure that the component behaves as +expected. We will use [MSW (Mock Service Worker)](https://mswjs.io/docs/getting-started) to mock the network requests. + +```tsx title=network-requests/Phonebook.test.tsx :::info -To prevent unexpected behavior, we ensure the following: -- Prevent the mock from resolving data multiple times by using `mockResolvedValueOnce`. -- Ensure the URL matches the base URL of the API by using a custom function `ensureUrlMatchesBaseUrl`. +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. ::: @@ -179,7 +271,7 @@ const DATA: { results: User[] } = { 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 + // examples/cookbook/app/advanced/__tests__/PhoneBook.test.tsx ... ], }; @@ -187,12 +279,15 @@ const DATA: { results: User[] } = { ``` ## Testing error handling -As we are dealing with network requests, we should also test how our application behaves when the API + +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. +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 @@ -242,6 +337,7 @@ export default { ``` ## 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. @@ -252,5 +348,8 @@ and how to guard against unwanted API requests throughout your test suite. ## Further Reading and Alternatives Explore more powerful tools for mocking network requests in your React Native application: -- [Axios Mock Adapter](https://github.com/ctimmerm/axios-mock-adapter): A popular library for mocking Axios calls with an extensive API, making it easy to simulate various scenarios. -- [MSW (Mock Service Worker)](https://mswjs.io/): Great for spinning up a local test server that intercepts network requests at the network level, providing end-to-end testing capabilities. + +- [Axios Mock Adapter](https://github.com/ctimmerm/axios-mock-adapter): A popular library for + mocking Axios calls with an extensive API, making it easy to simulate various scenarios. +- [MSW (Mock Service Worker)](https://mswjs.io/): Great for spinning up a local test server that + intercepts network requests at the network level, providing end-to-end testing capabilities. diff --git a/website/docs/12.x/cookbook/network-requests/_meta.json b/website/docs/12.x/cookbook/network-requests/_meta.json deleted file mode 100644 index 15b6e4062..000000000 --- a/website/docs/12.x/cookbook/network-requests/_meta.json +++ /dev/null @@ -1 +0,0 @@ -["axios", "fetch"] diff --git a/website/docs/12.x/cookbook/network-requests/fetch.md b/website/docs/12.x/cookbook/network-requests/fetch.md deleted file mode 100644 index 2e405d5a3..000000000 --- a/website/docs/12.x/cookbook/network-requests/fetch.md +++ /dev/null @@ -1,274 +0,0 @@ -# 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 -To prevent unexpected behavior, we ensure the following: -- Prevent the mock from resolving data multiple times by using `mockResolvedValueOnce`. -- Ensure the URL matches the base URL of the API by using a custom function `ensureUrlMatchesBaseUrl`. - -::: - - -```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 {MismatchedUrlError} from './test-utils'; - -const ensureUrlMatchesBaseUrl = (url: string) => { - if (!url.includes('https://randomuser.me/api')) throw new MismatchedUrlError(url); -}; - -export const mockFetchWithSuccessResponse = () => { - (global.fetch as jest.Mock).mockImplementationOnce((url) => { - // Ensure the URL matches the base URL of the API - ensureUrlMatchesBaseUrl(url); - - return Promise.resolve({ - ok: true, - json: jest.fn().mockResolvedValueOnce(DATA), - }); - }); -}; - -describe('PhoneBook', () => { - it('fetches contacts successfully and renders in list', async () => { - // mock the fetch function to return the data we want - mockFetchWithSuccessResponse(); - 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 -... - -export const mockFetchWithFailureResponse = () => { - (global.fetch as jest.Mock).mockImplementationOnce((url) => { - ensureUrlMatchesBaseUrl(url); - - return Promise.resolve({ - ok: false, - }); - }); -}; - -it('fails to fetch contacts and renders error message', async () => { - // mock the fetch function to be not ok which will throw an error - mockFetchWithFailureResponse(); - 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 always 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. - -## Further Reading and Alternatives - -Explore more powerful tools for mocking network requests in your React Native application: -- [Fetch Mock](https://www.wheresrhys.co.uk/fetch-mock/): A popular library for mocking fetch calls with an extensive API, making it easy to simulate various scenarios. -- [MSW (Mock Service Worker)](https://mswjs.io/): Great for spinning up a local test server that intercepts network requests at the network level, providing end-to-end testing capabilities. From 5d55fcdd325cfb7ef35bfcef482910e7e9e89af1 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Sat, 14 Sep 2024 06:08:13 +0200 Subject: [PATCH 10/12] updating docs (1) --- .../__tests__/PhoneBook.test.tsx | 6 +- .../cookbook/advanced/network-requests.md | 133 ++++++++++-------- 2 files changed, 82 insertions(+), 57 deletions(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index d61e298d1..f3665391e 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,6 +6,8 @@ import { mockServerFailureForGetAllFavorites, } from './test-utils'; +jest.setTimeout(10000); + describe('PhoneBook', () => { it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { render(); @@ -23,7 +25,9 @@ describe('PhoneBook', () => { render(); await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); - expect(await screen.findByText(/error fetching contacts/i)).toBeOnTheScreen(); + expect( + await screen.findByText(/an error occurred: error fetching contacts/i), + ).toBeOnTheScreen(); }); it('fails to fetch favorites and renders error message', async () => { diff --git a/website/docs/12.x/cookbook/advanced/network-requests.md b/website/docs/12.x/cookbook/advanced/network-requests.md index 6a609a946..1e22bc297 100644 --- a/website/docs/12.x/cookbook/advanced/network-requests.md +++ b/website/docs/12.x/cookbook/advanced/network-requests.md @@ -11,8 +11,7 @@ In this guide, we will show you how to mock network requests and guard your test 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. +To simulate a real-world scenario, we will use the [Random User Generator API](https://randomuser.me/) that provides random user data. ::: ## Phonebook Example @@ -87,7 +86,7 @@ export default async (): Promise => { }; ``` -We do the same for fetching the favorites, but this time limiting the results to 10. +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'; @@ -202,53 +201,60 @@ const styles = ... ## Start testing with a simple test -In our test we would like to test if the `PhoneBook` component renders the `FavoritesList` +In our initial test we would like to test if the `PhoneBook` component renders the `FavoritesList` and `ContactsList` components correctly. -We will mock the network requests and their responses to ensure that the component behaves as -expected. We will use [MSW (Mock Service Worker)](https://mswjs.io/docs/getting-started) to mock the network requests. +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). -```tsx title=network-requests/Phonebook.test.tsx +:::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 -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. +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, waitForElementToBeRemoved} from '@testing-library/react-native'; +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; import React from 'react'; import PhoneBook from '../PhoneBook'; -import {User} from '../types'; -import axios from 'axios'; -import {MismatchedUrlError} from './test-utils'; - -const ensureUrlMatchesBaseUrl = (url: string) => { - if (!url.includes('https://randomuser.me/api')) throw new MismatchedUrlError(url); -}; - -export const mockAxiosGetWithSuccessResponse = () => { - (axios.get as jest.Mock).mockImplementationOnce((url) => { - // Ensure the URL matches the base URL of the API - ensureUrlMatchesBaseUrl(url); - - return Promise.resolve({ data: DATA }); - }); -}; +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 favorites successfully and renders all users avatars', async () => { - // Mock the axios.get function to return the data we want - mockAxiosGetWithSuccessResponse(); - render(); + it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { + render(); - // Wait for the loader to disappear - await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + 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(); - // All the avatars should be rendered expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); }); -}); const DATA: { results: User[] } = { results: [ @@ -271,21 +277,22 @@ const DATA: { results: User[] } = { cell: '123-4567-890', }, // For brevity, we have omitted the rest of the users, you can still find them in - // examples/cookbook/app/advanced/__tests__/PhoneBook.test.tsx + // 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, 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. +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. -:::note +:::info It is good to note that Axios throws auto. an error when the response status code is not in the range of 2xx. ::: @@ -293,24 +300,38 @@ range of 2xx. ```tsx title=network-requests/Phonebook.test.tsx ... -export const mockAxiosGetWithFailureResponse = () => { - (axios.get as jest.Mock).mockImplementationOnce((url) => { - ensureUrlMatchesBaseUrl(url); - - return Promise.reject({ message: 'Error fetching favorites' }); - }); +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); + }), + ); }; -it('fails to fetch favorites and renders error message', async () => { - // Mock the axios.get function to throw an error - mockAxiosGetWithFailureResponse(); - 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(); +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 From ded40d1414aacb82aa8526556e7dd6bf630aba86 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Sat, 14 Sep 2024 06:21:10 +0200 Subject: [PATCH 11/12] updating docs (2) --- .../cookbook/advanced/network-requests.md | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/website/docs/12.x/cookbook/advanced/network-requests.md b/website/docs/12.x/cookbook/advanced/network-requests.md index 1e22bc297..4c033b140 100644 --- a/website/docs/12.x/cookbook/advanced/network-requests.md +++ b/website/docs/12.x/cookbook/advanced/network-requests.md @@ -293,8 +293,9 @@ As we are dealing with network requests, and things can go wrong, we should also the API request fails. In this case, we would like to test how our application behaves when the API request fails. :::info -It is good to note that Axios throws auto. an error when the response status code is not in the -range of 2xx. +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 @@ -337,24 +338,26 @@ describe('PhoneBook', () => { ## 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. +To prevent we make unwanted API requests, and alert the developer when it happens, we can 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=__mocks__/axios.ts -const chuckNorrisError = () => { - throw Error( - "Please ensure you mock 'Axios' - Only Chuck Norris is allowed to make API requests when testing ;)", - ); -}; +```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()); -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), -}; +// ... 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 From d7acda30dd4329f997a1f319edaad6671a32122b Mon Sep 17 00:00:00 2001 From: stevegalili Date: Mon, 16 Sep 2024 08:25:39 +0200 Subject: [PATCH 12/12] updating docs with global guarding and conclusion --- .../cookbook/advanced/network-requests.md | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/website/docs/12.x/cookbook/advanced/network-requests.md b/website/docs/12.x/cookbook/advanced/network-requests.md index 4c033b140..08002ef92 100644 --- a/website/docs/12.x/cookbook/advanced/network-requests.md +++ b/website/docs/12.x/cookbook/advanced/network-requests.md @@ -255,6 +255,7 @@ describe('PhoneBook', () => { expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); }); +}); const DATA: { results: User[] } = { results: [ @@ -337,8 +338,8 @@ describe('PhoneBook', () => { ## 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 choose to +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 @@ -362,18 +363,18 @@ Which will result in a warning in the console if you forget to mock an API reque ## 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 +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. -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. + +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 powerful tools for mocking network requests in your React Native application: +Explore more advanced scenarios for mocking network requests with MSW: -- [Axios Mock Adapter](https://github.com/ctimmerm/axios-mock-adapter): A popular library for - mocking Axios calls with an extensive API, making it easy to simulate various scenarios. -- [MSW (Mock Service Worker)](https://mswjs.io/): Great for spinning up a local test server that - intercepts network requests at the network level, providing end-to-end testing capabilities. +- 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