Skip to content

Commit bd98be0

Browse files
vanGalileastevegalili
and
stevegalili
authored
docs(cookbook): network requests recipes (#1655)
* squash prev commits * remove unneeded axios mock * set maxWorkers=2 * run with a slow test reporter * revert: run with a slow test reporter * Add url check to mock and further reading and alternatives * use MSW for all API calls in cookbook test suits * Comments with implem. explanation * Arrange docs initially to reflect new scenario and remove jest.setTimeout * updating docs (1) * updating docs (2) * updating docs with global guarding and conclusion --------- Co-authored-by: stevegalili <steve.galili@mywheels.nl>
1 parent 60dd2e6 commit bd98be0

File tree

17 files changed

+1091
-12
lines changed

17 files changed

+1091
-12
lines changed

examples/cookbook/app/index.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type Recipe = {
8282
};
8383

8484
const recipes: Recipe[] = [
85-
{ id: 2, title: 'Welcome Screen with Custom Render', path: 'custom-render/' },
86-
{ id: 1, title: 'Task List with Jotai', path: 'jotai/' },
85+
{ id: 1, title: 'Welcome Screen with Custom Render', path: 'custom-render/' },
86+
{ id: 2, title: 'Task List with Jotai', path: 'state-management/jotai/' },
87+
{ id: 3, title: 'Phone book with\na Variety of Net. Req. Methods', path: 'advanced/' },
8788
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { Text } from 'react-native';
3+
import { User } from './types';
4+
import ContactsList from './components/ContactsList';
5+
import FavoritesList from './components/FavoritesList';
6+
import getAllContacts from './api/getAllContacts';
7+
import getAllFavorites from './api/getAllFavorites';
8+
9+
export default () => {
10+
const [usersData, setUsersData] = useState<User[]>([]);
11+
const [favoritesData, setFavoritesData] = useState<User[]>([]);
12+
const [error, setError] = useState<string | null>(null);
13+
14+
useEffect(() => {
15+
const _getAllContacts = async () => {
16+
const _data = await getAllContacts();
17+
setUsersData(_data);
18+
};
19+
const _getAllFavorites = async () => {
20+
const _data = await getAllFavorites();
21+
setFavoritesData(_data);
22+
};
23+
24+
const run = async () => {
25+
try {
26+
await Promise.all([_getAllContacts(), _getAllFavorites()]);
27+
} catch (e) {
28+
const message = isErrorWithMessage(e) ? e.message : 'Something went wrong';
29+
setError(message);
30+
}
31+
};
32+
33+
void run();
34+
}, []);
35+
36+
if (error) {
37+
return <Text>An error occurred: {error}</Text>;
38+
}
39+
40+
return (
41+
<>
42+
<FavoritesList users={favoritesData} />
43+
<ContactsList users={usersData} />
44+
</>
45+
);
46+
};
47+
48+
const isErrorWithMessage = (
49+
e: unknown,
50+
): e is {
51+
message: string;
52+
} => typeof e === 'object' && e !== null && 'message' in e;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native';
2+
import React from 'react';
3+
import PhoneBook from '../PhoneBook';
4+
import {
5+
mockServerFailureForGetAllContacts,
6+
mockServerFailureForGetAllFavorites,
7+
} from './test-utils';
8+
9+
jest.setTimeout(10000);
10+
11+
describe('PhoneBook', () => {
12+
it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => {
13+
render(<PhoneBook />);
14+
15+
await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i));
16+
expect(await screen.findByText('Name: Mrs Ida Kristensen')).toBeOnTheScreen();
17+
expect(await screen.findByText('Email: ida.kristensen@example.com')).toBeOnTheScreen();
18+
expect(await screen.findAllByText(/name/i)).toHaveLength(3);
19+
expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen();
20+
expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3);
21+
});
22+
23+
it('fails to fetch all contacts and renders error message', async () => {
24+
mockServerFailureForGetAllContacts();
25+
render(<PhoneBook />);
26+
27+
await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i));
28+
expect(
29+
await screen.findByText(/an error occurred: error fetching contacts/i),
30+
).toBeOnTheScreen();
31+
});
32+
33+
it('fails to fetch favorites and renders error message', async () => {
34+
mockServerFailureForGetAllFavorites();
35+
render(<PhoneBook />);
36+
37+
await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i));
38+
expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen();
39+
});
40+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { User } from '../types';
2+
import {http, HttpResponse} from "msw";
3+
import {setupServer} from "msw/node";
4+
5+
// Define request handlers and response resolvers for random user API.
6+
// By default, we always return the happy path response.
7+
const handlers = [
8+
http.get('https://randomuser.me/api/*', () => {
9+
return HttpResponse.json(DATA);
10+
}),
11+
];
12+
13+
export const server = setupServer(...handlers);
14+
15+
export const mockServerFailureForGetAllContacts = () => {
16+
server.use(
17+
http.get('https://randomuser.me/api/', ({ request }) => {
18+
// Construct a URL instance out of the intercepted request.
19+
const url = new URL(request.url);
20+
// Read the "results" URL query parameter using the "URLSearchParams" API.
21+
const resultsLength = url.searchParams.get('results');
22+
// Simulate a server error for the get all contacts request.
23+
// We check if the "results" query parameter is set to "25"
24+
// to know it's the correct request to mock, in our case get all contacts.
25+
if (resultsLength === '25') {
26+
return new HttpResponse(null, { status: 500 });
27+
}
28+
29+
return HttpResponse.json(DATA);
30+
}),
31+
);
32+
};
33+
34+
export const mockServerFailureForGetAllFavorites = () => {
35+
server.use(
36+
http.get('https://randomuser.me/api/', ({ request }) => {
37+
// Construct a URL instance out of the intercepted request.
38+
const url = new URL(request.url);
39+
// Read the "results" URL query parameter using the "URLSearchParams" API.
40+
const resultsLength = url.searchParams.get('results');
41+
// Simulate a server error for the get all favorites request.
42+
// We check if the "results" query parameter is set to "10"
43+
// to know it's the correct request to mock, in our case get all favorites.
44+
if (resultsLength === '10') {
45+
return new HttpResponse(null, { status: 500 });
46+
}
47+
48+
return HttpResponse.json(DATA);
49+
}),
50+
);
51+
};
52+
export const DATA: { results: User[] } = {
53+
results: [
54+
{
55+
name: {
56+
title: 'Mrs',
57+
first: 'Ida',
58+
last: 'Kristensen',
59+
},
60+
email: 'ida.kristensen@example.com',
61+
id: {
62+
name: 'CPR',
63+
value: '250562-5730',
64+
},
65+
picture: {
66+
large: 'https://randomuser.me/api/portraits/women/26.jpg',
67+
medium: 'https://randomuser.me/api/portraits/med/women/26.jpg',
68+
thumbnail: 'https://randomuser.me/api/portraits/thumb/women/26.jpg',
69+
},
70+
cell: '123-4567-890',
71+
},
72+
{
73+
name: {
74+
title: 'Mr',
75+
first: 'Elijah',
76+
last: 'Ellis',
77+
},
78+
email: 'elijah.ellis@example.com',
79+
id: {
80+
name: 'TFN',
81+
value: '138117486',
82+
},
83+
picture: {
84+
large: 'https://randomuser.me/api/portraits/men/53.jpg',
85+
medium: 'https://randomuser.me/api/portraits/med/men/53.jpg',
86+
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/53.jpg',
87+
},
88+
cell: '123-4567-890',
89+
},
90+
{
91+
name: {
92+
title: 'Mr',
93+
first: 'Miro',
94+
last: 'Halko',
95+
},
96+
email: 'miro.halko@example.com',
97+
id: {
98+
name: 'HETU',
99+
value: 'NaNNA945undefined',
100+
},
101+
picture: {
102+
large: 'https://randomuser.me/api/portraits/men/17.jpg',
103+
medium: 'https://randomuser.me/api/portraits/med/men/17.jpg',
104+
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/17.jpg',
105+
},
106+
cell: '123-4567-890',
107+
},
108+
],
109+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { User } from '../types';
2+
3+
export default async (): Promise<User[]> => {
4+
const res = await fetch('https://randomuser.me/api/?results=25');
5+
if (!res.ok) {
6+
throw new Error(`Error fetching contacts`);
7+
}
8+
const json = await res.json();
9+
return json.results;
10+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { User } from '../types';
2+
3+
export default async (): Promise<User[]> => {
4+
const res = await fetch('https://randomuser.me/api/?results=10');
5+
if (!res.ok) {
6+
throw new Error(`Error fetching favorites`);
7+
}
8+
const json = await res.json();
9+
return json.results;
10+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { FlatList, Image, StyleSheet, Text, View } from 'react-native';
2+
import React, { useCallback } from 'react';
3+
import type { ListRenderItem } from '@react-native/virtualized-lists';
4+
import { User } from '../types';
5+
6+
export default ({ users }: { users: User[] }) => {
7+
const renderItem: ListRenderItem<User> = useCallback(
8+
({ item: { name, email, picture, cell }, index }) => {
9+
const { title, first, last } = name;
10+
const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff';
11+
return (
12+
<View style={[{ backgroundColor }, styles.userContainer]}>
13+
<Image source={{ uri: picture.thumbnail }} style={styles.userImage} />
14+
<View>
15+
<Text>
16+
Name: {title} {first} {last}
17+
</Text>
18+
<Text>Email: {email}</Text>
19+
<Text>Mobile: {cell}</Text>
20+
</View>
21+
</View>
22+
);
23+
},
24+
[],
25+
);
26+
27+
if (users.length === 0) return <FullScreenLoader />;
28+
29+
return (
30+
<View>
31+
<FlatList<User>
32+
data={users}
33+
renderItem={renderItem}
34+
keyExtractor={(item, index) => `${index}-${item.id.value}`}
35+
/>
36+
</View>
37+
);
38+
};
39+
const FullScreenLoader = () => {
40+
return (
41+
<View style={styles.loaderContainer}>
42+
<Text>Users data not quite there yet...</Text>
43+
</View>
44+
);
45+
};
46+
47+
const styles = StyleSheet.create({
48+
userContainer: {
49+
padding: 16,
50+
flexDirection: 'row',
51+
alignItems: 'center',
52+
},
53+
userImage: {
54+
width: 50,
55+
height: 50,
56+
borderRadius: 24,
57+
marginRight: 16,
58+
},
59+
loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
60+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { FlatList, Image, StyleSheet, Text, View } from 'react-native';
2+
import React, { useCallback } from 'react';
3+
import type { ListRenderItem } from '@react-native/virtualized-lists';
4+
import { User } from '../types';
5+
6+
export default ({ users }: { users: User[] }) => {
7+
const renderItem: ListRenderItem<User> = useCallback(({ item: { picture } }) => {
8+
return (
9+
<View style={styles.userContainer}>
10+
<Image
11+
source={{ uri: picture.thumbnail }}
12+
style={styles.userImage}
13+
accessibilityLabel={'favorite-contact-avatar'}
14+
/>
15+
</View>
16+
);
17+
}, []);
18+
19+
if (users.length === 0) return <FullScreenLoader />;
20+
21+
return (
22+
<View style={styles.outerContainer}>
23+
<Text>⭐My Favorites</Text>
24+
<FlatList<User>
25+
horizontal
26+
showsHorizontalScrollIndicator={false}
27+
data={users}
28+
renderItem={renderItem}
29+
keyExtractor={(item, index) => `${index}-${item.id.value}`}
30+
/>
31+
</View>
32+
);
33+
};
34+
const FullScreenLoader = () => {
35+
return (
36+
<View style={styles.loaderContainer}>
37+
<Text>Figuring out your favorites...</Text>
38+
</View>
39+
);
40+
};
41+
42+
const styles = StyleSheet.create({
43+
outerContainer: {
44+
padding: 8,
45+
},
46+
userContainer: {
47+
padding: 8,
48+
flexDirection: 'row',
49+
alignItems: 'center',
50+
},
51+
userImage: {
52+
width: 52,
53+
height: 52,
54+
borderRadius: 36,
55+
borderColor: '#9b6dff',
56+
borderWidth: 2,
57+
},
58+
loaderContainer: { height: 52, justifyContent: 'center', alignItems: 'center' },
59+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as React from 'react';
2+
import PhoneBook from './PhoneBook';
3+
4+
export default function Example() {
5+
return <PhoneBook />;
6+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type User = {
2+
name: {
3+
title: string;
4+
first: string;
5+
last: string;
6+
};
7+
email: string;
8+
id: {
9+
name: string;
10+
value: string;
11+
};
12+
picture: {
13+
large: string;
14+
medium: string;
15+
thumbnail: string;
16+
};
17+
cell: string;
18+
};

examples/cookbook/jest-setup.ts

+8
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
// Import built-in Jest matchers
44
import '@testing-library/react-native/extend-expect';
5+
import { server } from './app/network-requests/__tests__/test-utils';
56

67
// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
78
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');
9+
10+
// Enable API mocking via Mock Service Worker (MSW)
11+
beforeAll(() => server.listen());
12+
// Reset any runtime request handlers we may add during the tests
13+
afterEach(() => server.resetHandlers());
14+
// Disable API mocking after the tests are done
15+
afterAll(() => server.close());

0 commit comments

Comments
 (0)