Skip to content

Commit

Permalink
docs(cookbook): network requests recipes (#1655)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
vanGalilea and stevegalili authored Sep 18, 2024
1 parent 60dd2e6 commit bd98be0
Show file tree
Hide file tree
Showing 17 changed files with 1,091 additions and 12 deletions.
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: 'advanced/' },
];
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(() => {
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,40 @@
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native';
import React from 'react';
import PhoneBook from '../PhoneBook';
import {
mockServerFailureForGetAllContacts,
mockServerFailureForGetAllFavorites,
} from './test-utils';

jest.setTimeout(10000);

describe('PhoneBook', () => {
it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => {
render(<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: ida.kristensen@example.com')).toBeOnTheScreen();
expect(await screen.findAllByText(/name/i)).toHaveLength(3);
expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen();
expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3);
});

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

await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i));
expect(
await screen.findByText(/an error occurred: error fetching contacts/i),
).toBeOnTheScreen();
});

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

await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i));
expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen();
});
});
109 changes: 109 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,109 @@
import { User } from '../types';
import {http, HttpResponse} from "msw";
import {setupServer} from "msw/node";

// Define request handlers and response resolvers for random user API.
// By default, we always return the happy path response.
const handlers = [
http.get('https://randomuser.me/api/*', () => {
return HttpResponse.json(DATA);
}),
];

export const server = setupServer(...handlers);

export const mockServerFailureForGetAllContacts = () => {
server.use(
http.get('https://randomuser.me/api/', ({ request }) => {
// Construct a URL instance out of the intercepted request.
const url = new URL(request.url);
// Read the "results" URL query parameter using the "URLSearchParams" API.
const resultsLength = url.searchParams.get('results');
// Simulate a server error for the get all contacts request.
// We check if the "results" query parameter is set to "25"
// to know it's the correct request to mock, in our case get all contacts.
if (resultsLength === '25') {
return new HttpResponse(null, { status: 500 });
}

return HttpResponse.json(DATA);
}),
);
};

export const mockServerFailureForGetAllFavorites = () => {
server.use(
http.get('https://randomuser.me/api/', ({ request }) => {
// Construct a URL instance out of the intercepted request.
const url = new URL(request.url);
// Read the "results" URL query parameter using the "URLSearchParams" API.
const resultsLength = url.searchParams.get('results');
// Simulate a server error for the get all favorites request.
// We check if the "results" query parameter is set to "10"
// to know it's the correct request to mock, in our case get all favorites.
if (resultsLength === '10') {
return new HttpResponse(null, { status: 500 });
}

return HttpResponse.json(DATA);
}),
);
};
export const DATA: { results: User[] } = {
results: [
{
name: {
title: 'Mrs',
first: 'Ida',
last: 'Kristensen',
},
email: 'ida.kristensen@example.com',
id: {
name: 'CPR',
value: '250562-5730',
},
picture: {
large: 'https://randomuser.me/api/portraits/women/26.jpg',
medium: 'https://randomuser.me/api/portraits/med/women/26.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/women/26.jpg',
},
cell: '123-4567-890',
},
{
name: {
title: 'Mr',
first: 'Elijah',
last: 'Ellis',
},
email: 'elijah.ellis@example.com',
id: {
name: 'TFN',
value: '138117486',
},
picture: {
large: 'https://randomuser.me/api/portraits/men/53.jpg',
medium: 'https://randomuser.me/api/portraits/med/men/53.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/53.jpg',
},
cell: '123-4567-890',
},
{
name: {
title: 'Mr',
first: 'Miro',
last: 'Halko',
},
email: 'miro.halko@example.com',
id: {
name: 'HETU',
value: 'NaNNA945undefined',
},
picture: {
large: 'https://randomuser.me/api/portraits/men/17.jpg',
medium: 'https://randomuser.me/api/portraits/med/men/17.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/17.jpg',
},
cell: '123-4567-890',
},
],
};
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;
};
10 changes: 10 additions & 0 deletions examples/cookbook/app/network-requests/api/getAllFavorites.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=10');
if (!res.ok) {
throw new Error(`Error fetching favorites`);
}
const json = await res.json();
return json.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 />;
}
18 changes: 18 additions & 0 deletions examples/cookbook/app/network-requests/types.ts
Original file line number Diff line number Diff line change
@@ -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;
};
8 changes: 8 additions & 0 deletions examples/cookbook/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

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

// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

// Enable API mocking via Mock Service Worker (MSW)
beforeAll(() => server.listen());
// Reset any runtime request handlers we may add during the tests
afterEach(() => server.resetHandlers());
// Disable API mocking after the tests are done
afterAll(() => server.close());
Loading

0 comments on commit bd98be0

Please sign in to comment.