Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs(cookbook): network requests recipes #1655

Merged
merged 13 commits into from
Sep 18, 2024
14 changes: 14 additions & 0 deletions examples/cookbook/__mocks__/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const chuckNorrisError = () => {
vanGalilea marked this conversation as resolved.
Show resolved Hide resolved
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),
vanGalilea marked this conversation as resolved.
Show resolved Hide resolved
post: jest.fn(chuckNorrisError),
put: jest.fn(chuckNorrisError),
delete: jest.fn(chuckNorrisError),
request: jest.fn(chuckNorrisError),
};
5 changes: 3 additions & 2 deletions examples/cookbook/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
];
52 changes: 52 additions & 0 deletions examples/cookbook/app/network-requests/PhoneBook.tsx
Original file line number Diff line number Diff line change
@@ -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<User[]>([]);
const [favoritesData, setFavoritesData] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thinking out loud, perhaps we should showcase usage with TanStack Query instead of manual promise fetching. Implementing data fetching using useEffect to avoid race conditions is verbose. Wdyt @vanGalilea ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Showcase is a very good idea, as it is become and industry standard the last years.
With my approach I intended to keep things more straight-forward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me add it up, this week 👍🏻 In the meanwhile, you can review the rest ;)

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 <Text>An error occurred: {error}</Text>;
}

return (
<>
<FavoritesList users={favoritesData} />
<ContactsList users={usersData} />
</>
);
};

const isErrorWithMessage = (
e: unknown,
): e is {
message: string;
} => typeof e === 'object' && e !== null && 'message' in e;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native';
import React from 'react';
import PhoneBook from '../PhoneBook';
import {
mockAxiosGetWithFailureResponse,
mockAxiosGetWithSuccessResponse,
mockFetchWithFailureResponse,
mockFetchWithSuccessResponse,
} from './test-utils';

jest.setTimeout(10000);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this test suit runs in CI, the default 5s is not sufficient.
Locally it's a matter of 0.91 s.

Any idea what might be causing this on CI? @mdjastrzebski?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to my measuring:

renders an empty task list ran in 6612ms
PhoneBook fetches contacts successfully and renders in list ran in 7190ms
Top 8 slowest examples (14.071 seconds, 100.0% of total time):
  PhoneBook fetches contacts successfully and renders in list
    7.19 seconds ./app/network-requests/__tests__/PhoneBook.test.tsx
  renders an empty task list
    6.612 seconds ./app/state-management/jotai/__tests__/TaskList.test.tsx
  renders WelcomeScreen in light theme
    0.078 seconds ./app/custom-render/__tests__/index.test.tsx
  PhoneBook fetches favorites successfully and renders all users avatars
    0.0[5](https://github.com/callstack/react-native-testing-library/actions/runs/10590927775/job/29347478417?pr=1655#step:6:6)6 seconds ./app/network-requests/__tests__/PhoneBook.test.tsx
  PhoneBook fails to fetch contacts and renders error message
    0.052 seconds ./app/network-requests/__tests__/PhoneBook.test.tsx
  PhoneBook fails to fetch favorites and renders error message
    0.052 seconds ./app/network-requests/__tests__/PhoneBook.test.tsx
  renders a to do list with 1 items initially, and adds a new item
    0.02[6](https://github.com/callstack/react-native-testing-library/actions/runs/10590927775/job/29347478417?pr=1655#step:6:7) seconds ./app/state-management/jotai/__tests__/TaskList.test.tsx
  renders WelcomeScreen in dark theme
    0.005 seconds ./app/custom-render/__tests__/index.test.tsx

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like the spec. tests "renders an empty task list" and "PhoneBook fetches contacts successfully and renders in list" are the slowest as they're the 1st tests in the file 🤷🏻


describe('PhoneBook', () => {
it('fetches contacts successfully and renders in list', async () => {
mockFetchWithSuccessResponse();
mockAxiosGetWithSuccessResponse();
render(<PhoneBook />);

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: [email protected]')).toBeOnTheScreen();
expect(await screen.findAllByText(/name/i)).toHaveLength(3);
});

it('fails to fetch contacts and renders error message', async () => {
mockFetchWithFailureResponse();
mockAxiosGetWithSuccessResponse();
render(<PhoneBook />);

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(<PhoneBook />);

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();
render(<PhoneBook />);

await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i));
expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen();
});
});
113 changes: 113 additions & 0 deletions examples/cookbook/app/network-requests/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
vanGalilea marked this conversation as resolved.
Show resolved Hide resolved
(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[] } = {
vanGalilea marked this conversation as resolved.
Show resolved Hide resolved
results: [
{
name: {
title: 'Mrs',
first: 'Ida',
last: 'Kristensen',
},
email: '[email protected]',
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: '[email protected]',
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: '[email protected]',
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',
},
],
};
10 changes: 10 additions & 0 deletions examples/cookbook/app/network-requests/api/getAllContacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { User } from '../types';

export default async (): Promise<User[]> => {
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;
};
7 changes: 7 additions & 0 deletions examples/cookbook/app/network-requests/api/getAllFavorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import axios from 'axios';
import { User } from '../types';

export default async (): Promise<User[]> => {
const res = await axios.get('https://randomuser.me/api/?results=10');
return res.data.results;
};
60 changes: 60 additions & 0 deletions examples/cookbook/app/network-requests/components/ContactsList.tsx
Original file line number Diff line number Diff line change
@@ -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<User> = useCallback(
({ item: { name, email, picture, cell }, index }) => {
const { title, first, last } = name;
const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff';
return (
<View style={[{ backgroundColor }, styles.userContainer]}>
<Image source={{ uri: picture.thumbnail }} style={styles.userImage} />
<View>
<Text>
Name: {title} {first} {last}
</Text>
<Text>Email: {email}</Text>
<Text>Mobile: {cell}</Text>
</View>
</View>
);
},
[],
);

if (users.length === 0) return <FullScreenLoader />;

return (
<View>
<FlatList<User>
data={users}
renderItem={renderItem}
keyExtractor={(item, index) => `${index}-${item.id.value}`}
/>
</View>
);
};
const FullScreenLoader = () => {
return (
<View style={styles.loaderContainer}>
<Text>Users data not quite there yet...</Text>
</View>
);
};

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' },
});
Original file line number Diff line number Diff line change
@@ -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<User> = useCallback(({ item: { picture } }) => {
return (
<View style={styles.userContainer}>
<Image
source={{ uri: picture.thumbnail }}
style={styles.userImage}
accessibilityLabel={'favorite-contact-avatar'}
/>
</View>
);
}, []);

if (users.length === 0) return <FullScreenLoader />;

return (
<View style={styles.outerContainer}>
<Text>⭐My Favorites</Text>
<FlatList<User>
horizontal
showsHorizontalScrollIndicator={false}
data={users}
renderItem={renderItem}
keyExtractor={(item, index) => `${index}-${item.id.value}`}
/>
</View>
);
};
const FullScreenLoader = () => {
return (
<View style={styles.loaderContainer}>
<Text>Figuring out your favorites...</Text>
</View>
);
};

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' },
});
6 changes: 6 additions & 0 deletions examples/cookbook/app/network-requests/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react';
import PhoneBook from './PhoneBook';

export default function Example() {
return <PhoneBook />;
}
Loading