From cbb1c24d7e8cb8a78947a27ef936f4377c49a65b Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 23 Jul 2024 08:51:39 +0200 Subject: [PATCH 01/24] example code --- .../network-requests/UsersListFetch.tsx | 391 ++++++++++++++++++ .../__tests__/UsersListFetch.tsx | 40 ++ .../12.x/cookbook/network-requests/axios.md | 1 + .../12.x/cookbook/network-requests/fetch.md | 1 + .../12.x/cookbook/network-requests/msw.md | 1 + 5 files changed, 434 insertions(+) create mode 100644 examples/cookbook/network-requests/UsersListFetch.tsx create mode 100644 examples/cookbook/network-requests/__tests__/UsersListFetch.tsx create mode 100644 website/docs/12.x/cookbook/network-requests/axios.md create mode 100644 website/docs/12.x/cookbook/network-requests/fetch.md create mode 100644 website/docs/12.x/cookbook/network-requests/msw.md diff --git a/examples/cookbook/network-requests/UsersListFetch.tsx b/examples/cookbook/network-requests/UsersListFetch.tsx new file mode 100644 index 000000000..e78248b56 --- /dev/null +++ b/examples/cookbook/network-requests/UsersListFetch.tsx @@ -0,0 +1,391 @@ +import { FlatList, Text, View } from 'react-native'; +import React, { useCallback, useEffect, useState } from 'react'; +import type { ListRenderItem } from '@react-native/virtualized-lists'; + +type User = { + name: { + title: string; + first: string; + last: string; + }; + email: string; + id: { + name: string; + value: string; + }; +}; + +export default () => { + const [data, setData] = useState([]); + useEffect(() => { + const run = async () => { + const _data = await fetchData(); + setData(_data); + }; + + void run(); + }, []); + + const renderItem: ListRenderItem = useCallback(({ item: { name, email } }) => { + const { title, first, last } = name; + return ( + <> + + Name: {title} {first} {last} + + Email: {email} + + ); + }, []); + return ( + + data={data} renderItem={renderItem} keyExtractor={(item) => item.id.value} /> + + ); +}; + +const fetchData = async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=10'); + return await res.json(); +}; + +export const DATA: { results: User[] } = { + results: [ + { + gender: 'female', + name: { + title: 'Mrs', + first: 'Ida', + last: 'Kristensen', + }, + location: { + street: { + number: 2949, + name: 'Erantisvej', + }, + city: 'Hornbæk', + state: 'Nordjylland', + country: 'Denmark', + postcode: 78056, + coordinates: { + latitude: '-40.8235', + longitude: '163.8050', + }, + timezone: { + offset: '+7:00', + description: 'Bangkok, Hanoi, Jakarta', + }, + }, + email: 'ida.kristensen@example.com', + login: { + uuid: '559d4249-cde4-4461-9f9c-8d51401af2b5', + username: 'silverlion833', + password: 'marble', + salt: '24rqAef9', + md5: '6320d32ecfe5e8e5741c2af3922f5a42', + sha1: 'a23ef223661f78700ebd146c1933b18b87d31294', + sha256: '5383017fe686e315e4ebe240128f2c3c168ffeba9ccb3b1fe3ba78f72dad0225', + }, + dob: { + date: '1962-05-25T18:38:10.820Z', + age: 62, + }, + registered: { + date: '2004-02-02T11:51:23.484Z', + age: 20, + }, + phone: '01691638', + cell: '40104495', + 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', + }, + nat: 'DK', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Barry', + last: 'Steward', + }, + location: { + street: { + number: 1921, + name: 'Locust Rd', + }, + city: 'Busselton', + state: 'Western Australia', + country: 'Australia', + postcode: 4999, + coordinates: { + latitude: '63.1959', + longitude: '-2.5579', + }, + timezone: { + offset: '+1:00', + description: 'Brussels, Copenhagen, Madrid, Paris', + }, + }, + email: 'barry.steward@example.com', + login: { + uuid: '02077f89-b500-431d-b374-e0eb3fca9571', + username: 'bigdog124', + password: 'impreza', + salt: '4Rn9WOJw', + md5: 'a59d5df8a316f0516ac720de70ff9984', + sha1: '2a28929cd277ba2a7aa6af237b5178f981d39a24', + sha256: 'f7e0cab464e6237ab744947a0d81a83eebf5416f5d73e5ebca0034a6c3883abf', + }, + dob: { + date: '1952-10-04T23:18:08.221Z', + age: 71, + }, + registered: { + date: '2007-07-01T07:28:31.663Z', + age: 17, + }, + phone: '01-3956-5636', + cell: '0431-154-199', + id: { + name: 'TFN', + value: '396148093', + }, + picture: { + large: 'https://randomuser.me/api/portraits/men/73.jpg', + medium: 'https://randomuser.me/api/portraits/med/men/73.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/men/73.jpg', + }, + nat: 'AU', + }, + { + gender: 'female', + name: { + title: 'Mrs', + first: 'Nurdan', + last: 'Tanrıkulu', + }, + location: { + street: { + number: 1649, + name: 'Abanoz Sk', + }, + city: 'İzmir', + state: 'Kastamonu', + country: 'Turkey', + postcode: 12236, + coordinates: { + latitude: '-60.1904', + longitude: '-170.0327', + }, + timezone: { + offset: '+3:00', + description: 'Baghdad, Riyadh, Moscow, St. Petersburg', + }, + }, + email: 'nurdan.tanrikulu@example.com', + login: { + uuid: '24b26e9a-8a6b-4512-861f-f5f936d0e57e', + username: 'brownpeacock362', + password: 'playtime', + salt: 'OleOXtTP', + md5: 'e6db746d780e15546d056c6cfc3d0596', + sha1: 'f91fd1d1d07d1c1e77ba5e4239d59119e02f0c72', + sha256: '12dc899d99a3e070d624c1425ce162119972eb027629a11a3d57c4731d6f13e0', + }, + dob: { + date: '1981-09-06T15:04:24.958Z', + age: 42, + }, + registered: { + date: '2010-11-06T07:44:26.522Z', + age: 13, + }, + phone: '(216)-483-4039', + cell: '(404)-334-2627', + id: { + name: '', + value: null, + }, + picture: { + large: 'https://randomuser.me/api/portraits/women/94.jpg', + medium: 'https://randomuser.me/api/portraits/med/women/94.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/women/94.jpg', + }, + nat: 'TR', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Elijah', + last: 'Ellis', + }, + location: { + street: { + number: 5049, + name: 'Edwards Rd', + }, + city: 'Australian Capital Territory', + state: 'Western Australia', + country: 'Australia', + postcode: 2678, + coordinates: { + latitude: '72.7709', + longitude: '92.3434', + }, + timezone: { + offset: '-11:00', + description: 'Midway Island, Samoa', + }, + }, + email: 'elijah.ellis@example.com', + login: { + uuid: 'c5826670-a31a-4c5b-ab3b-4a6f42a7e470', + username: 'brownleopard128', + password: 'funny', + salt: '8XFQHn3b', + md5: 'ff79f92e8f590cd0a32b5789a440eab1', + sha1: '6244732c4ea030b901657f2a770e3c64c1882290', + sha256: 'ce00727610927378f84ae32738d3837b0bd326fcabf61483db473f4bb9ca8d13', + }, + dob: { + date: '2000-01-19T13:38:11.062Z', + age: 24, + }, + registered: { + date: '2014-04-10T00:16:14.729Z', + age: 10, + }, + phone: '00-7157-8777', + cell: '0418-427-029', + 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', + }, + nat: 'AU', + }, + { + gender: 'female', + name: { + title: 'Mrs', + first: 'Rianna', + last: 'Cegelskiy', + }, + location: { + street: { + number: 5024, + name: 'Magistratska', + }, + city: 'Gorlivka', + state: 'Ivano-Frankivska', + country: 'Ukraine', + postcode: 11184, + coordinates: { + latitude: '-34.7506', + longitude: '175.7042', + }, + timezone: { + offset: '-9:00', + description: 'Alaska', + }, + }, + email: 'rianna.cegelskiy@example.com', + login: { + uuid: '6074474a-3b03-4430-831e-dfc8b83d0538', + username: 'purplegorilla527', + password: 'bremen', + salt: 'kll5ul1K', + md5: '7c0905f85d9955c39c1dfc4fde758421', + sha1: 'df8a154b09b6bf9d557dea54882fce505e119af1', + sha256: '87c162f5213fb2e949cb0bb609224c053c414e6be0a46a80a9e36e8454754752', + }, + dob: { + date: '1977-10-09T20:29:29.511Z', + age: 46, + }, + registered: { + date: '2014-06-04T21:40:50.237Z', + age: 10, + }, + phone: '(066) V30-8592', + cell: '(096) C21-8101', + id: { + name: '', + value: null, + }, + picture: { + large: 'https://randomuser.me/api/portraits/women/68.jpg', + medium: 'https://randomuser.me/api/portraits/med/women/68.jpg', + thumbnail: 'https://randomuser.me/api/portraits/thumb/women/68.jpg', + }, + nat: 'UA', + }, + { + gender: 'male', + name: { + title: 'Mr', + first: 'Miro', + last: 'Halko', + }, + location: { + street: { + number: 4891, + name: 'Hämeenkatu', + }, + city: 'Kiikoinen', + state: 'North Karelia', + country: 'Finland', + postcode: 20659, + coordinates: { + latitude: '-84.3128', + longitude: '171.3976', + }, + timezone: { + offset: '+2:00', + description: 'Kaliningrad, South Africa', + }, + }, + email: 'miro.halko@example.com', + login: { + uuid: '980b28ce-ccc2-4d73-b04e-35d22b7ea4ef', + username: 'heavymeercat231', + password: 'wingman', + salt: '8syuBg1y', + md5: 'ae1cf1e11ab4e70f1a02843b14d85093', + sha1: 'f0f10deae6af9e31abbcd090afdd040174a8d1fa', + sha256: '8e672cfc3a8fde8af41dd48a00d55100630f1d350d7be4546b370e2706050207', + }, + dob: { + date: '1960-07-13T10:52:34.013Z', + age: 64, + }, + registered: { + date: '2013-07-04T21:31:46.009Z', + age: 11, + }, + phone: '03-397-597', + cell: '045-053-53-68', + 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', + }, + nat: 'FI', + }, + ], +}; diff --git a/examples/cookbook/network-requests/__tests__/UsersListFetch.tsx b/examples/cookbook/network-requests/__tests__/UsersListFetch.tsx new file mode 100644 index 000000000..b099be607 --- /dev/null +++ b/examples/cookbook/network-requests/__tests__/UsersListFetch.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react-native'; +import UsersListFetch from '../UsersListFetch'; +import React from 'react'; + +beforeAll(() => { + jest.spyOn(global, 'fetch').mockImplementation( + jest.fn(() => { + throw Error('Only Chuck Norris is allowed to make API requests when testing ;)'); + }), + ); +}); + +afterAll(() => { + (global.fetch as jest.Mock).mockRestore(); +}); + +describe('UsersListFetch', () => { + it('fetches users successfully and renders in list', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce([ + { + email: 'gerri@nos.nl', + name: { + title: 'Prof.', + first: 'Gerri', + last: 'Eickhof', + }, + id: { + name: 'abcdef', + value: 'abc-123', + }, + }, + ]), + } as unknown as Response); + render(); + + await screen.findByText('Email: gerri@nos.nl'); + await screen.findByText('Name: Prof. Gerri Eickhof'); + }); +}); 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..8d5748e8a --- /dev/null +++ b/website/docs/12.x/cookbook/network-requests/axios.md @@ -0,0 +1 @@ +# Fetch 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..8d5748e8a --- /dev/null +++ b/website/docs/12.x/cookbook/network-requests/fetch.md @@ -0,0 +1 @@ +# Fetch diff --git a/website/docs/12.x/cookbook/network-requests/msw.md b/website/docs/12.x/cookbook/network-requests/msw.md new file mode 100644 index 000000000..8d5748e8a --- /dev/null +++ b/website/docs/12.x/cookbook/network-requests/msw.md @@ -0,0 +1 @@ +# Fetch From 89df27eb1b33ee9150bcb48e17c5cc07df0e8f38 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 20 Aug 2024 08:44:25 +0200 Subject: [PATCH 02/24] add users list with fetch example and corres. minimal test --- examples/cookbook/app/index.tsx | 5 +- .../app/network-requests/UsersHome.tsx | 31 ++ .../__tests__/UsersListFetch.test.tsx | 86 ++++ .../network-requests/components/UserList.tsx | 59 +++ .../cookbook/app/network-requests/index.tsx | 6 + .../cookbook/app/network-requests/types.ts | 17 + .../network-requests/UsersListFetch.tsx | 391 ------------------ .../__tests__/UsersListFetch.tsx | 40 -- 8 files changed, 202 insertions(+), 433 deletions(-) create mode 100644 examples/cookbook/app/network-requests/UsersHome.tsx create mode 100644 examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx create mode 100644 examples/cookbook/app/network-requests/components/UserList.tsx create mode 100644 examples/cookbook/app/network-requests/index.tsx create mode 100644 examples/cookbook/app/network-requests/types.ts delete mode 100644 examples/cookbook/network-requests/UsersListFetch.tsx delete mode 100644 examples/cookbook/network-requests/__tests__/UsersListFetch.tsx diff --git a/examples/cookbook/app/index.tsx b/examples/cookbook/app/index.tsx index 025a57d29..d1ec1bcd5 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: 'Users Home with\na Variety of Net. Req. Methods', path: 'network-requests/' }, ]; diff --git a/examples/cookbook/app/network-requests/UsersHome.tsx b/examples/cookbook/app/network-requests/UsersHome.tsx new file mode 100644 index 000000000..08a386767 --- /dev/null +++ b/examples/cookbook/app/network-requests/UsersHome.tsx @@ -0,0 +1,31 @@ +import React, { useEffect, useState } from 'react'; +import { User } from './types'; +import UserList from './components/UserList'; + +export default () => { + const [data, setData] = useState([]); + useEffect(() => { + const run = async () => { + const _data = await getUsersWithFetch(); + setData(_data); + }; + + void run(); + }, []); + + return ( + <> + {/*Todo: maybe this should be a phone book app with all contacts, catch up suggestions at the top woth avatar*/} + {/*add phonbe number to user maybe?*/} + {/* TODO: Add Catch up with... UserSuggestionsList with avatar horizontally in top that will utilize axios for example*/} + {/*maybe add one example of react query?*/} + + + ); +}; + +const getUsersWithFetch = async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=25'); + const json = await res.json(); + return json.results; +}; diff --git a/examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx b/examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx new file mode 100644 index 000000000..ec2dd23dd --- /dev/null +++ b/examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; +import React from 'react'; +import UsersHome from '../UsersHome'; +import { User } from '../types'; + +beforeAll(() => { + jest.spyOn(global, 'fetch').mockImplementation( + jest.fn(() => { + throw Error('Only Chuck Norris is allowed to make API requests when testing ;)'); + }), + ); +}); + +afterAll(() => { + (global.fetch as jest.Mock).mockRestore(); +}); + +describe('UsersListFetch', () => { + it('fetches users successfully and renders in list', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce(DATA), + } as unknown as Response); + 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); + }); +}); + +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', + }, + }, + { + 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', + }, + }, + { + 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', + }, + }, + ], +}; diff --git a/examples/cookbook/app/network-requests/components/UserList.tsx b/examples/cookbook/app/network-requests/components/UserList.tsx new file mode 100644 index 000000000..919a2e690 --- /dev/null +++ b/examples/cookbook/app/network-requests/components/UserList.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: { name, email, picture }, index }) => { + const { title, first, last } = name; + const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff'; + return ( + + + + + Name: {title} {first} {last} + + Email: {email} + + + ); + }, + [], + ); + + 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/index.tsx b/examples/cookbook/app/network-requests/index.tsx new file mode 100644 index 000000000..7824c2cd3 --- /dev/null +++ b/examples/cookbook/app/network-requests/index.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import UsersHome from './UsersHome'; + +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..4134cc594 --- /dev/null +++ b/examples/cookbook/app/network-requests/types.ts @@ -0,0 +1,17 @@ +export type User = { + name: { + title: string; + first: string; + last: string; + }; + email: string; + id: { + name: string; + value: string; + }; + picture: { + large: string; + medium: string; + thumbnail: string; + }; +}; diff --git a/examples/cookbook/network-requests/UsersListFetch.tsx b/examples/cookbook/network-requests/UsersListFetch.tsx deleted file mode 100644 index e78248b56..000000000 --- a/examples/cookbook/network-requests/UsersListFetch.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import { FlatList, Text, View } from 'react-native'; -import React, { useCallback, useEffect, useState } from 'react'; -import type { ListRenderItem } from '@react-native/virtualized-lists'; - -type User = { - name: { - title: string; - first: string; - last: string; - }; - email: string; - id: { - name: string; - value: string; - }; -}; - -export default () => { - const [data, setData] = useState([]); - useEffect(() => { - const run = async () => { - const _data = await fetchData(); - setData(_data); - }; - - void run(); - }, []); - - const renderItem: ListRenderItem = useCallback(({ item: { name, email } }) => { - const { title, first, last } = name; - return ( - <> - - Name: {title} {first} {last} - - Email: {email} - - ); - }, []); - return ( - - data={data} renderItem={renderItem} keyExtractor={(item) => item.id.value} /> - - ); -}; - -const fetchData = async (): Promise => { - const res = await fetch('https://randomuser.me/api/?results=10'); - return await res.json(); -}; - -export const DATA: { results: User[] } = { - results: [ - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Ida', - last: 'Kristensen', - }, - location: { - street: { - number: 2949, - name: 'Erantisvej', - }, - city: 'Hornbæk', - state: 'Nordjylland', - country: 'Denmark', - postcode: 78056, - coordinates: { - latitude: '-40.8235', - longitude: '163.8050', - }, - timezone: { - offset: '+7:00', - description: 'Bangkok, Hanoi, Jakarta', - }, - }, - email: 'ida.kristensen@example.com', - login: { - uuid: '559d4249-cde4-4461-9f9c-8d51401af2b5', - username: 'silverlion833', - password: 'marble', - salt: '24rqAef9', - md5: '6320d32ecfe5e8e5741c2af3922f5a42', - sha1: 'a23ef223661f78700ebd146c1933b18b87d31294', - sha256: '5383017fe686e315e4ebe240128f2c3c168ffeba9ccb3b1fe3ba78f72dad0225', - }, - dob: { - date: '1962-05-25T18:38:10.820Z', - age: 62, - }, - registered: { - date: '2004-02-02T11:51:23.484Z', - age: 20, - }, - phone: '01691638', - cell: '40104495', - 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', - }, - nat: 'DK', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Barry', - last: 'Steward', - }, - location: { - street: { - number: 1921, - name: 'Locust Rd', - }, - city: 'Busselton', - state: 'Western Australia', - country: 'Australia', - postcode: 4999, - coordinates: { - latitude: '63.1959', - longitude: '-2.5579', - }, - timezone: { - offset: '+1:00', - description: 'Brussels, Copenhagen, Madrid, Paris', - }, - }, - email: 'barry.steward@example.com', - login: { - uuid: '02077f89-b500-431d-b374-e0eb3fca9571', - username: 'bigdog124', - password: 'impreza', - salt: '4Rn9WOJw', - md5: 'a59d5df8a316f0516ac720de70ff9984', - sha1: '2a28929cd277ba2a7aa6af237b5178f981d39a24', - sha256: 'f7e0cab464e6237ab744947a0d81a83eebf5416f5d73e5ebca0034a6c3883abf', - }, - dob: { - date: '1952-10-04T23:18:08.221Z', - age: 71, - }, - registered: { - date: '2007-07-01T07:28:31.663Z', - age: 17, - }, - phone: '01-3956-5636', - cell: '0431-154-199', - id: { - name: 'TFN', - value: '396148093', - }, - picture: { - large: 'https://randomuser.me/api/portraits/men/73.jpg', - medium: 'https://randomuser.me/api/portraits/med/men/73.jpg', - thumbnail: 'https://randomuser.me/api/portraits/thumb/men/73.jpg', - }, - nat: 'AU', - }, - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Nurdan', - last: 'Tanrıkulu', - }, - location: { - street: { - number: 1649, - name: 'Abanoz Sk', - }, - city: 'İzmir', - state: 'Kastamonu', - country: 'Turkey', - postcode: 12236, - coordinates: { - latitude: '-60.1904', - longitude: '-170.0327', - }, - timezone: { - offset: '+3:00', - description: 'Baghdad, Riyadh, Moscow, St. Petersburg', - }, - }, - email: 'nurdan.tanrikulu@example.com', - login: { - uuid: '24b26e9a-8a6b-4512-861f-f5f936d0e57e', - username: 'brownpeacock362', - password: 'playtime', - salt: 'OleOXtTP', - md5: 'e6db746d780e15546d056c6cfc3d0596', - sha1: 'f91fd1d1d07d1c1e77ba5e4239d59119e02f0c72', - sha256: '12dc899d99a3e070d624c1425ce162119972eb027629a11a3d57c4731d6f13e0', - }, - dob: { - date: '1981-09-06T15:04:24.958Z', - age: 42, - }, - registered: { - date: '2010-11-06T07:44:26.522Z', - age: 13, - }, - phone: '(216)-483-4039', - cell: '(404)-334-2627', - id: { - name: '', - value: null, - }, - picture: { - large: 'https://randomuser.me/api/portraits/women/94.jpg', - medium: 'https://randomuser.me/api/portraits/med/women/94.jpg', - thumbnail: 'https://randomuser.me/api/portraits/thumb/women/94.jpg', - }, - nat: 'TR', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Elijah', - last: 'Ellis', - }, - location: { - street: { - number: 5049, - name: 'Edwards Rd', - }, - city: 'Australian Capital Territory', - state: 'Western Australia', - country: 'Australia', - postcode: 2678, - coordinates: { - latitude: '72.7709', - longitude: '92.3434', - }, - timezone: { - offset: '-11:00', - description: 'Midway Island, Samoa', - }, - }, - email: 'elijah.ellis@example.com', - login: { - uuid: 'c5826670-a31a-4c5b-ab3b-4a6f42a7e470', - username: 'brownleopard128', - password: 'funny', - salt: '8XFQHn3b', - md5: 'ff79f92e8f590cd0a32b5789a440eab1', - sha1: '6244732c4ea030b901657f2a770e3c64c1882290', - sha256: 'ce00727610927378f84ae32738d3837b0bd326fcabf61483db473f4bb9ca8d13', - }, - dob: { - date: '2000-01-19T13:38:11.062Z', - age: 24, - }, - registered: { - date: '2014-04-10T00:16:14.729Z', - age: 10, - }, - phone: '00-7157-8777', - cell: '0418-427-029', - 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', - }, - nat: 'AU', - }, - { - gender: 'female', - name: { - title: 'Mrs', - first: 'Rianna', - last: 'Cegelskiy', - }, - location: { - street: { - number: 5024, - name: 'Magistratska', - }, - city: 'Gorlivka', - state: 'Ivano-Frankivska', - country: 'Ukraine', - postcode: 11184, - coordinates: { - latitude: '-34.7506', - longitude: '175.7042', - }, - timezone: { - offset: '-9:00', - description: 'Alaska', - }, - }, - email: 'rianna.cegelskiy@example.com', - login: { - uuid: '6074474a-3b03-4430-831e-dfc8b83d0538', - username: 'purplegorilla527', - password: 'bremen', - salt: 'kll5ul1K', - md5: '7c0905f85d9955c39c1dfc4fde758421', - sha1: 'df8a154b09b6bf9d557dea54882fce505e119af1', - sha256: '87c162f5213fb2e949cb0bb609224c053c414e6be0a46a80a9e36e8454754752', - }, - dob: { - date: '1977-10-09T20:29:29.511Z', - age: 46, - }, - registered: { - date: '2014-06-04T21:40:50.237Z', - age: 10, - }, - phone: '(066) V30-8592', - cell: '(096) C21-8101', - id: { - name: '', - value: null, - }, - picture: { - large: 'https://randomuser.me/api/portraits/women/68.jpg', - medium: 'https://randomuser.me/api/portraits/med/women/68.jpg', - thumbnail: 'https://randomuser.me/api/portraits/thumb/women/68.jpg', - }, - nat: 'UA', - }, - { - gender: 'male', - name: { - title: 'Mr', - first: 'Miro', - last: 'Halko', - }, - location: { - street: { - number: 4891, - name: 'Hämeenkatu', - }, - city: 'Kiikoinen', - state: 'North Karelia', - country: 'Finland', - postcode: 20659, - coordinates: { - latitude: '-84.3128', - longitude: '171.3976', - }, - timezone: { - offset: '+2:00', - description: 'Kaliningrad, South Africa', - }, - }, - email: 'miro.halko@example.com', - login: { - uuid: '980b28ce-ccc2-4d73-b04e-35d22b7ea4ef', - username: 'heavymeercat231', - password: 'wingman', - salt: '8syuBg1y', - md5: 'ae1cf1e11ab4e70f1a02843b14d85093', - sha1: 'f0f10deae6af9e31abbcd090afdd040174a8d1fa', - sha256: '8e672cfc3a8fde8af41dd48a00d55100630f1d350d7be4546b370e2706050207', - }, - dob: { - date: '1960-07-13T10:52:34.013Z', - age: 64, - }, - registered: { - date: '2013-07-04T21:31:46.009Z', - age: 11, - }, - phone: '03-397-597', - cell: '045-053-53-68', - 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', - }, - nat: 'FI', - }, - ], -}; diff --git a/examples/cookbook/network-requests/__tests__/UsersListFetch.tsx b/examples/cookbook/network-requests/__tests__/UsersListFetch.tsx deleted file mode 100644 index b099be607..000000000 --- a/examples/cookbook/network-requests/__tests__/UsersListFetch.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { render, screen } from '@testing-library/react-native'; -import UsersListFetch from '../UsersListFetch'; -import React from 'react'; - -beforeAll(() => { - jest.spyOn(global, 'fetch').mockImplementation( - jest.fn(() => { - throw Error('Only Chuck Norris is allowed to make API requests when testing ;)'); - }), - ); -}); - -afterAll(() => { - (global.fetch as jest.Mock).mockRestore(); -}); - -describe('UsersListFetch', () => { - it('fetches users successfully and renders in list', async () => { - jest.spyOn(global, 'fetch').mockResolvedValueOnce({ - json: jest.fn().mockResolvedValueOnce([ - { - email: 'gerri@nos.nl', - name: { - title: 'Prof.', - first: 'Gerri', - last: 'Eickhof', - }, - id: { - name: 'abcdef', - value: 'abc-123', - }, - }, - ]), - } as unknown as Response); - render(); - - await screen.findByText('Email: gerri@nos.nl'); - await screen.findByText('Name: Prof. Gerri Eickhof'); - }); -}); From 49e755d0f25d01d08aac9c22cf5ee979a6b39670 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Mon, 26 Aug 2024 18:48:10 +0200 Subject: [PATCH 03/24] rename to PhoneBook and add FavoritesList --- examples/cookbook/app/index.tsx | 2 +- .../app/network-requests/PhoneBook.tsx | 34 ++++++++++++ .../app/network-requests/UsersHome.tsx | 31 ----------- ...sListFetch.test.tsx => PhoneBook.test.tsx} | 36 ++++++++++-- .../network-requests/api/getAllContacts.ts | 7 +++ .../network-requests/api/getAllFavorites.ts | 7 +++ .../{UserList.tsx => ContactsList.tsx} | 3 +- .../components/FavoritesList.tsx | 55 +++++++++++++++++++ .../cookbook/app/network-requests/index.tsx | 4 +- .../cookbook/app/network-requests/types.ts | 1 + examples/cookbook/package.json | 1 + examples/cookbook/yarn.lock | 40 ++++++++++++++ 12 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 examples/cookbook/app/network-requests/PhoneBook.tsx delete mode 100644 examples/cookbook/app/network-requests/UsersHome.tsx rename examples/cookbook/app/network-requests/__tests__/{UsersListFetch.test.tsx => PhoneBook.test.tsx} (68%) create mode 100644 examples/cookbook/app/network-requests/api/getAllContacts.ts create mode 100644 examples/cookbook/app/network-requests/api/getAllFavorites.ts rename examples/cookbook/app/network-requests/components/{UserList.tsx => ContactsList.tsx} (93%) create mode 100644 examples/cookbook/app/network-requests/components/FavoritesList.tsx diff --git a/examples/cookbook/app/index.tsx b/examples/cookbook/app/index.tsx index d1ec1bcd5..c6955de1f 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: 'Users Home with\na Variety of Net. Req. Methods', path: 'network-requests/' }, + { 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..d3002523c --- /dev/null +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +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([]); + useEffect(() => { + const _getAllContacts = async () => { + const _data = await getAllContacts(); + setUsersData(_data); + }; + const _getAllFavorites = async () => { + const _data = await getAllFavorites(); + setFavoritesData(_data); + }; + + const run = async () => { + await Promise.all([_getAllContacts(), _getAllFavorites()]); + }; + + void run(); + }, []); + + return ( + <> + + + + ); +}; diff --git a/examples/cookbook/app/network-requests/UsersHome.tsx b/examples/cookbook/app/network-requests/UsersHome.tsx deleted file mode 100644 index 08a386767..000000000 --- a/examples/cookbook/app/network-requests/UsersHome.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { User } from './types'; -import UserList from './components/UserList'; - -export default () => { - const [data, setData] = useState([]); - useEffect(() => { - const run = async () => { - const _data = await getUsersWithFetch(); - setData(_data); - }; - - void run(); - }, []); - - return ( - <> - {/*Todo: maybe this should be a phone book app with all contacts, catch up suggestions at the top woth avatar*/} - {/*add phonbe number to user maybe?*/} - {/* TODO: Add Catch up with... UserSuggestionsList with avatar horizontally in top that will utilize axios for example*/} - {/*maybe add one example of react query?*/} - - - ); -}; - -const getUsersWithFetch = async (): Promise => { - const res = await fetch('https://randomuser.me/api/?results=25'); - const json = await res.json(); - return json.results; -}; diff --git a/examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx similarity index 68% rename from examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx rename to examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index ec2dd23dd..91a89cfa7 100644 --- a/examples/cookbook/app/network-requests/__tests__/UsersListFetch.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -1,8 +1,11 @@ import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; import React from 'react'; -import UsersHome from '../UsersHome'; +import PhoneBook from '../PhoneBook'; import { User } from '../types'; +// Ensure that the global fetch function is mocked in your setup file +// before running the tests. This will ensure that the tests don't +// make any actual network requests that you haven't mocked. beforeAll(() => { jest.spyOn(global, 'fetch').mockImplementation( jest.fn(() => { @@ -15,18 +18,36 @@ afterAll(() => { (global.fetch as jest.Mock).mockRestore(); }); -describe('UsersListFetch', () => { - it('fetches users successfully and renders in list', async () => { - jest.spyOn(global, 'fetch').mockResolvedValueOnce({ +describe('PhoneBook', () => { + let originalFetch: typeof global.fetch; + beforeAll(() => { + originalFetch = global.fetch; + global.fetch = jest.fn().mockResolvedValueOnce({ json: jest.fn().mockResolvedValueOnce(DATA), - } as unknown as Response); - render(); + }); + //TODO: mock axios + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('fetches contacts successfully and renders in list', 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); }); + + it('fetches favorites successfully and renders in list', async () => { + render(); + + await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); + expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); + expect(await screen.findAllByText(/name/i)).toHaveLength(3); + }); }); const DATA: { results: User[] } = { @@ -47,6 +68,7 @@ const DATA: { results: User[] } = { 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: { @@ -64,6 +86,7 @@ const DATA: { results: User[] } = { 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: { @@ -81,6 +104,7 @@ const DATA: { results: User[] } = { 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..b6a6ae4e1 --- /dev/null +++ b/examples/cookbook/app/network-requests/api/getAllContacts.ts @@ -0,0 +1,7 @@ +import { User } from '../types'; + +export default async (): Promise => { + const res = await fetch('https://randomuser.me/api/?results=25'); + 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/UserList.tsx b/examples/cookbook/app/network-requests/components/ContactsList.tsx similarity index 93% rename from examples/cookbook/app/network-requests/components/UserList.tsx rename to examples/cookbook/app/network-requests/components/ContactsList.tsx index 919a2e690..f62f99f00 100644 --- a/examples/cookbook/app/network-requests/components/UserList.tsx +++ b/examples/cookbook/app/network-requests/components/ContactsList.tsx @@ -5,7 +5,7 @@ import { User } from '../types'; export default ({ users }: { users: User[] }) => { const renderItem: ListRenderItem = useCallback( - ({ item: { name, email, picture }, index }) => { + ({ item: { name, email, picture, cell }, index }) => { const { title, first, last } = name; const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff'; return ( @@ -16,6 +16,7 @@ export default ({ users }: { users: User[] }) => { Name: {title} {first} {last} Email: {email} + Mobile: {cell} ); 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..5500fd6b2 --- /dev/null +++ b/examples/cookbook/app/network-requests/components/FavoritesList.tsx @@ -0,0 +1,55 @@ +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 index 7824c2cd3..86075de32 100644 --- a/examples/cookbook/app/network-requests/index.tsx +++ b/examples/cookbook/app/network-requests/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import UsersHome from './UsersHome'; +import PhoneBook from './PhoneBook'; export default function Example() { - return ; + return ; } diff --git a/examples/cookbook/app/network-requests/types.ts b/examples/cookbook/app/network-requests/types.ts index 4134cc594..f198d644d 100644 --- a/examples/cookbook/app/network-requests/types.ts +++ b/examples/cookbook/app/network-requests/types.ts @@ -14,4 +14,5 @@ export type User = { medium: string; thumbnail: string; }; + cell: string; }; 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" From fd2cba8b40566fe5de4ba21d557889d7826e871a Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 08:02:21 +0200 Subject: [PATCH 04/24] Test harness and proper mocking --- examples/cookbook/__mocks__/axios.ts | 13 +++++++ .../__tests__/PhoneBook.test.tsx | 38 ++++++------------- .../components/FavoritesList.tsx | 6 ++- examples/cookbook/jest-setup.ts | 13 +++++++ 4 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 examples/cookbook/__mocks__/axios.ts 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/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 91a89cfa7..f58d2c647 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -2,37 +2,17 @@ import { render, screen, waitForElementToBeRemoved } from '@testing-library/reac import React from 'react'; import PhoneBook from '../PhoneBook'; import { User } from '../types'; +import axios from 'axios'; -// Ensure that the global fetch function is mocked in your setup file -// before running the tests. This will ensure that the tests don't -// make any actual network requests that you haven't mocked. -beforeAll(() => { - jest.spyOn(global, 'fetch').mockImplementation( - jest.fn(() => { - throw Error('Only Chuck Norris is allowed to make API requests when testing ;)'); - }), - ); -}); - -afterAll(() => { - (global.fetch as jest.Mock).mockRestore(); -}); +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; describe('PhoneBook', () => { - let originalFetch: typeof global.fetch; - beforeAll(() => { - originalFetch = global.fetch; - global.fetch = jest.fn().mockResolvedValueOnce({ + it('fetches contacts successfully and renders in list', async () => { + (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ json: jest.fn().mockResolvedValueOnce(DATA), }); - //TODO: mock axios - }); - - afterAll(() => { - global.fetch = originalFetch; - }); - - it('fetches contacts successfully and renders in list', async () => { + (mockedAxios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); @@ -42,11 +22,15 @@ describe('PhoneBook', () => { }); it('fetches favorites successfully and renders in list', async () => { + (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce(DATA), + }); + (mockedAxios.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.findAllByText(/name/i)).toHaveLength(3); + expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); }); }); diff --git a/examples/cookbook/app/network-requests/components/FavoritesList.tsx b/examples/cookbook/app/network-requests/components/FavoritesList.tsx index 5500fd6b2..17503200c 100644 --- a/examples/cookbook/app/network-requests/components/FavoritesList.tsx +++ b/examples/cookbook/app/network-requests/components/FavoritesList.tsx @@ -7,7 +7,11 @@ export default ({ users }: { users: User[] }) => { const renderItem: ListRenderItem = useCallback(({ item: { picture } }) => { return ( - + ); }, []); diff --git a/examples/cookbook/jest-setup.ts b/examples/cookbook/jest-setup.ts index 7f63025d9..cbdd5a9c2 100644 --- a/examples/cookbook/jest-setup.ts +++ b/examples/cookbook/jest-setup.ts @@ -5,3 +5,16 @@ 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(() => { + (global.fetch as jest.Mock).mockRestore(); +}); From cfdbe429d8927698742ca888f474be6705a84f8b Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 09:13:52 +0200 Subject: [PATCH 05/24] Compose Axios recipe --- .../app/network-requests/PhoneBook.tsx | 13 +- .../__tests__/PhoneBook.test.tsx | 20 +- website/docs/12.x/cookbook/_meta.json | 5 + .../12.x/cookbook/network-requests/_meta.json | 1 + .../12.x/cookbook/network-requests/axios.md | 226 +++++++++++++++++- .../12.x/cookbook/network-requests/msw.md | 1 - .../12.x/cookbook/state-management/jotai.md | 12 +- 7 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 website/docs/12.x/cookbook/network-requests/_meta.json delete mode 100644 website/docs/12.x/cookbook/network-requests/msw.md diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index d3002523c..cbbce335e 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -1,4 +1,5 @@ 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'; @@ -8,6 +9,8 @@ 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(); @@ -19,12 +22,20 @@ export default () => { }; const run = async () => { - await Promise.all([_getAllContacts(), _getAllFavorites()]); + try { + await Promise.all([_getAllContacts(), _getAllFavorites()]); + } catch (e) { + setError(e.message); + } }; void run(); }, []); + if (error) { + return An error occurred: {error}; + } + return ( <> diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index f58d2c647..fb7944bd9 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -1,18 +1,17 @@ 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 axios from 'axios'; jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ json: jest.fn().mockResolvedValueOnce(DATA), }); - (mockedAxios.get as jest.Mock).mockResolvedValue({ data: DATA }); + (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); @@ -21,17 +20,28 @@ describe('PhoneBook', () => { expect(await screen.findAllByText(/name/i)).toHaveLength(3); }); - it('fetches favorites successfully and renders in list', async () => { + it('fetches favorites successfully and renders all users avatars', async () => { (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ json: jest.fn().mockResolvedValueOnce(DATA), }); - (mockedAxios.get as jest.Mock).mockResolvedValue({ data: 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.SpyInstance).mockResolvedValueOnce({ + 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[] } = { 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 index 8d5748e8a..b6dbc4552 100644 --- a/website/docs/12.x/cookbook/network-requests/axios.md +++ b/website/docs/12.x/cookbook/network-requests/axios.md @@ -1 +1,225 @@ -# Fetch +# 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/axios/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/axios/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/axios/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. +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/axios/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/axios/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/msw.md b/website/docs/12.x/cookbook/network-requests/msw.md deleted file mode 100644 index 8d5748e8a..000000000 --- a/website/docs/12.x/cookbook/network-requests/msw.md +++ /dev/null @@ -1 +0,0 @@ -# Fetch 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 d105659f120c017759445c53f1fef154ba694033 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 09:50:15 +0200 Subject: [PATCH 06/24] Compose Fetch recipe --- .../__tests__/PhoneBook.test.tsx | 14 + .../network-requests/api/getAllContacts.ts | 3 + examples/cookbook/jest-setup.ts | 1 + .../12.x/cookbook/network-requests/axios.md | 16 +- .../12.x/cookbook/network-requests/fetch.md | 241 ++++++++++++++++++ 5 files changed, 268 insertions(+), 7 deletions(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index fb7944bd9..d93769b74 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -9,6 +9,7 @@ jest.mock('axios'); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + ok: true, json: jest.fn().mockResolvedValueOnce(DATA), }); (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); @@ -20,8 +21,20 @@ describe('PhoneBook', () => { expect(await screen.findAllByText(/name/i)).toHaveLength(3); }); + it('fails to fetch contacts and renders error message', async () => { + (global.fetch as jest.SpyInstance).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.SpyInstance).mockResolvedValueOnce({ + ok: true, json: jest.fn().mockResolvedValueOnce(DATA), }); (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); @@ -34,6 +47,7 @@ describe('PhoneBook', () => { it('fails to fetch favorites and renders error message', async () => { (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + ok: true, json: jest.fn().mockResolvedValueOnce(DATA), }); (axios.get as jest.Mock).mockRejectedValueOnce({ message: 'Error fetching favorites' }); diff --git a/examples/cookbook/app/network-requests/api/getAllContacts.ts b/examples/cookbook/app/network-requests/api/getAllContacts.ts index b6a6ae4e1..118f242da 100644 --- a/examples/cookbook/app/network-requests/api/getAllContacts.ts +++ b/examples/cookbook/app/network-requests/api/getAllContacts.ts @@ -2,6 +2,9 @@ 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/jest-setup.ts b/examples/cookbook/jest-setup.ts index cbdd5a9c2..1938288c4 100644 --- a/examples/cookbook/jest-setup.ts +++ b/examples/cookbook/jest-setup.ts @@ -16,5 +16,6 @@ beforeAll(() => { // see examples/cookbook/__mocks__/axios.ts }); afterAll(() => { + // restore the original fetch function (global.fetch as jest.Mock).mockRestore(); }); diff --git a/website/docs/12.x/cookbook/network-requests/axios.md b/website/docs/12.x/cookbook/network-requests/axios.md index b6dbc4552..cbf756d89 100644 --- a/website/docs/12.x/cookbook/network-requests/axios.md +++ b/website/docs/12.x/cookbook/network-requests/axios.md @@ -8,8 +8,7 @@ In this guide, we will show you how to mock Axios requests and guard your test s 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. +To simulate a real-world scenario, we will use the [Random User Generator API](https://randomuser.me/) that provides random user data. ::: ## Phonebook Example @@ -19,7 +18,7 @@ In our case, we have a list of favorite contacts that we want to display in our This is how the root of the application looks like: -```tsx title=network-requests/axios/Phonebook.tsx +```tsx title=network-requests/Phonebook.tsx import React, {useEffect, useState} from 'react'; import {User} from './types'; import FavoritesList from './components/FavoritesList'; @@ -54,7 +53,7 @@ export default () => { We fetch the contacts from the server using the `getAllFavorites` function that utilizes Axios. -```tsx title=network-requests/axios/api/getAllFavorites.ts +```tsx title=network-requests/api/getAllFavorites.ts import axios from 'axios'; import {User} from '../types'; @@ -68,7 +67,7 @@ export default async (): Promise => { Our `FavoritesList` component is a simple component that displays the list of favorite contacts and their avatars. -```tsx title=network-requests/axios/components/FavoritesList.tsx +```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'; @@ -116,10 +115,13 @@ 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/axios/Phonebook.test.tsx +```tsx title=network-requests/Phonebook.test.tsx import {render, waitForElementToBeRemoved} from '@testing-library/react-native'; import React from 'react'; import PhoneBook from '../PhoneBook'; @@ -179,7 +181,7 @@ handling the error correctly. 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/axios/Phonebook.test.tsx +```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 diff --git a/website/docs/12.x/cookbook/network-requests/fetch.md b/website/docs/12.x/cookbook/network-requests/fetch.md index 8d5748e8a..bd18441cd 100644 --- a/website/docs/12.x/cookbook/network-requests/fetch.md +++ b/website/docs/12.x/cookbook/network-requests/fetch.md @@ -1 +1,242 @@ # 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.SpyInstance).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. From 652d6de9f8a0b6dfffb7e58c39dc254ae2723b10 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 27 Aug 2024 16:54:50 +0200 Subject: [PATCH 07/24] Fix tune TS errors --- examples/cookbook/app/network-requests/PhoneBook.tsx | 3 ++- .../app/network-requests/__tests__/PhoneBook.test.tsx | 8 ++++---- website/docs/12.x/cookbook/network-requests/fetch.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index cbbce335e..cd437f1a3 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -25,7 +25,8 @@ export default () => { try { await Promise.all([_getAllContacts(), _getAllFavorites()]); } catch (e) { - setError(e.message); + const message = e instanceof Error ? e.message : JSON.stringify(e); + setError(message); } }; diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index d93769b74..3b2f2a794 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -8,7 +8,7 @@ jest.mock('axios'); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { - (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValueOnce(DATA), }); @@ -22,7 +22,7 @@ describe('PhoneBook', () => { }); it('fails to fetch contacts and renders error message', async () => { - (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, }); (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); @@ -33,7 +33,7 @@ describe('PhoneBook', () => { }); it('fetches favorites successfully and renders all users avatars', async () => { - (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValueOnce(DATA), }); @@ -46,7 +46,7 @@ describe('PhoneBook', () => { }); it('fails to fetch favorites and renders error message', async () => { - (global.fetch as jest.SpyInstance).mockResolvedValueOnce({ + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: jest.fn().mockResolvedValueOnce(DATA), }); diff --git a/website/docs/12.x/cookbook/network-requests/fetch.md b/website/docs/12.x/cookbook/network-requests/fetch.md index bd18441cd..d4865c235 100644 --- a/website/docs/12.x/cookbook/network-requests/fetch.md +++ b/website/docs/12.x/cookbook/network-requests/fetch.md @@ -199,7 +199,7 @@ See MDN's [docs](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Usin ... 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.SpyInstance).mockResolvedValueOnce({ + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, }); render(); From fb1f45b4f10895b34e8a4411d527c5d09588171d Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 27 Aug 2024 17:02:22 +0200 Subject: [PATCH 08/24] Fix info label --- website/docs/12.x/cookbook/network-requests/fetch.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/docs/12.x/cookbook/network-requests/fetch.md b/website/docs/12.x/cookbook/network-requests/fetch.md index d4865c235..690561d1e 100644 --- a/website/docs/12.x/cookbook/network-requests/fetch.md +++ b/website/docs/12.x/cookbook/network-requests/fetch.md @@ -7,8 +7,7 @@ simple and clean API for making requests. In this guide, we will show you how to 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/) +To simulate a real-world scenario, we will use the [Random User Generator API](https://randomuser.me/) that provides random user data. ::: From d52e36e44753eb115c3810be70f2603e60a00a93 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 27 Aug 2024 17:06:16 +0200 Subject: [PATCH 09/24] Sanity check failing test in CI --- .../__tests__/PhoneBook.test.tsx | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 3b2f2a794..14dd1a3f8 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -15,47 +15,47 @@ describe('PhoneBook', () => { (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); + // 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(); - }); + // 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[] } = { From a37017d5191dc80d2b04b1dc7f100e822af176ee Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 27 Aug 2024 17:08:00 +0200 Subject: [PATCH 10/24] Sanity check failing test in CI 2 - add waitForElementToBeRemoved --- .../cookbook/app/network-requests/__tests__/PhoneBook.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 14dd1a3f8..2f03d54a8 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -15,7 +15,7 @@ describe('PhoneBook', () => { (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); - // await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/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); From 70791e58be24fb3c0051f65670c4e6029981147e Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 27 Aug 2024 17:09:55 +0200 Subject: [PATCH 11/24] Sanity check failing test in CI 3 - remove waitForElementToBeRemoved add findBy* --- .../app/network-requests/__tests__/PhoneBook.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 2f03d54a8..974f939f0 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -15,10 +15,10 @@ describe('PhoneBook', () => { (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); + // 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 () => { From 11e9407b3094bc61e8e05800922bfe008047e367 Mon Sep 17 00:00:00 2001 From: stevegalili Date: Tue, 27 Aug 2024 17:14:31 +0200 Subject: [PATCH 12/24] Sanity check failing test in CI 4 - add console.logs --- examples/cookbook/app/network-requests/PhoneBook.tsx | 8 +++++++- .../app/network-requests/__tests__/PhoneBook.test.tsx | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index cd437f1a3..221eccad0 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -15,6 +15,9 @@ export default () => { const _getAllContacts = async () => { const _data = await getAllContacts(); setUsersData(_data); + console.log({ + _data, + }) }; const _getAllFavorites = async () => { const _data = await getAllFavorites(); @@ -25,13 +28,16 @@ export default () => { try { await Promise.all([_getAllContacts(), _getAllFavorites()]); } catch (e) { - const message = e instanceof Error ? e.message : JSON.stringify(e); + const message = e instanceof Error ? e.message : 'Something went wrong'; setError(message); } }; void run(); }, []); + console.log({ + usersData + }) if (error) { return An error occurred: {error}; diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 974f939f0..8a2a38a31 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -15,7 +15,7 @@ describe('PhoneBook', () => { (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); - // await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/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); From bf6b1f4a7cb251091d893a122f98930971bca6b2 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 19:44:11 +0200 Subject: [PATCH 13/24] Debug tests failing in CI --- examples/cookbook/app/network-requests/PhoneBook.tsx | 8 ++------ .../app/network-requests/__tests__/PhoneBook.test.tsx | 3 +++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index 221eccad0..2f31915a5 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -15,9 +15,6 @@ export default () => { const _getAllContacts = async () => { const _data = await getAllContacts(); setUsersData(_data); - console.log({ - _data, - }) }; const _getAllFavorites = async () => { const _data = await getAllFavorites(); @@ -35,9 +32,8 @@ export default () => { void run(); }, []); - console.log({ - usersData - }) + + console.log({ usersData, favoritesData, error, time: new Date().toISOString() }); if (error) { return An error occurred: {error}; diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 8a2a38a31..37598329a 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -15,6 +15,9 @@ describe('PhoneBook', () => { (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + screen.debug(); + 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(); From 20f0aa62304277eb8b9826015411f4221e76ba86 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 19:54:22 +0200 Subject: [PATCH 14/24] Debug tests failing in CI 2 --- .../cookbook/app/network-requests/PhoneBook.tsx | 2 +- .../network-requests/__tests__/PhoneBook.test.tsx | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index 2f31915a5..e124fdbce 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -33,7 +33,7 @@ export default () => { void run(); }, []); - console.log({ usersData, favoritesData, error, time: new Date().toISOString() }); + console.log({ 'usersData.length': usersData.length, time: new Date().toISOString() }); if (error) { return An error occurred: {error}; diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 37598329a..ecc4b733e 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,6 +6,7 @@ import { User } from '../types'; jest.mock('axios'); +jest.setTimeout(20000); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ @@ -15,13 +16,16 @@ describe('PhoneBook', () => { (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); screen.debug(); - - await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); + await new Promise((resolve) => setTimeout(resolve, 1000)); + screen.debug(); + await new Promise((resolve) => setTimeout(resolve, 1000)); + screen.debug(); + // 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('Email: ida.kristensen@example.com')).toBeOnTheScreen(); + // expect(await screen.findAllByText(/name/i)).toHaveLength(3); }); // it('fails to fetch contacts and renders error message', async () => { From 1eef2243e307ed9346452e398075e1d14a64c027 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 19:57:20 +0200 Subject: [PATCH 15/24] Debug tests failing in CI 3 --- .../app/network-requests/__tests__/PhoneBook.test.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index ecc4b733e..e5a19e44c 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -20,12 +20,10 @@ describe('PhoneBook', () => { screen.debug(); await new Promise((resolve) => setTimeout(resolve, 1000)); screen.debug(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - screen.debug(); // 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('Email: ida.kristensen@example.com')).toBeOnTheScreen(); + expect(await screen.findAllByText(/name/i)).toHaveLength(3); }); // it('fails to fetch contacts and renders error message', async () => { From 4608c88644b65106fc5a9fc908d28fac65cd14af Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 20:02:19 +0200 Subject: [PATCH 16/24] jest.setTimeout to 7 s --- .../app/network-requests/PhoneBook.tsx | 4 +- .../__tests__/PhoneBook.test.tsx | 78 +++++++++---------- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index e124fdbce..61b4bc0ca 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -25,7 +25,7 @@ export default () => { try { await Promise.all([_getAllContacts(), _getAllFavorites()]); } catch (e) { - const message = e instanceof Error ? e.message : 'Something went wrong'; + const message = 'message' in e ? e.message : 'Something went wrong'; setError(message); } }; @@ -33,8 +33,6 @@ export default () => { void run(); }, []); - console.log({ 'usersData.length': usersData.length, time: new Date().toISOString() }); - if (error) { return An error occurred: {error}; } diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index e5a19e44c..7932551f8 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,7 +6,7 @@ import { User } from '../types'; jest.mock('axios'); -jest.setTimeout(20000); +jest.setTimeout(7000); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ @@ -16,51 +16,47 @@ describe('PhoneBook', () => { (axios.get as jest.Mock).mockResolvedValue({ data: DATA }); render(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - screen.debug(); - await new Promise((resolve) => setTimeout(resolve, 1000)); - screen.debug(); - // await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/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); }); - // 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(); - // }); + 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[] } = { From e2aab55de4e4dee01b19c5cf15df2eb4d4b66c12 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 20:08:14 +0200 Subject: [PATCH 17/24] isKnownError --- examples/cookbook/app/network-requests/PhoneBook.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index 61b4bc0ca..31e90deb6 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -5,6 +5,7 @@ import ContactsList from './components/ContactsList'; import FavoritesList from './components/FavoritesList'; import getAllContacts from './api/getAllContacts'; import getAllFavorites from './api/getAllFavorites'; +import { isAxiosError } from 'axios'; export default () => { const [usersData, setUsersData] = useState([]); @@ -25,7 +26,8 @@ export default () => { try { await Promise.all([_getAllContacts(), _getAllFavorites()]); } catch (e) { - const message = 'message' in e ? e.message : 'Something went wrong'; + const isKnownError = e instanceof Error || isAxiosError(e); + const message = isKnownError ? e.message : 'Something went wrong'; setError(message); } }; From 852da820aadd765a275af5f376423e2e490c05a7 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 20:57:46 +0200 Subject: [PATCH 18/24] Increase to 10s --- .../cookbook/app/network-requests/__tests__/PhoneBook.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 7932551f8..5cd9b43c8 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,7 +6,7 @@ import { User } from '../types'; jest.mock('axios'); -jest.setTimeout(7000); +jest.setTimeout(10000); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ From f7c35ccdd3736ed45ed6b911d9880ba08976bdc1 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 21:11:23 +0200 Subject: [PATCH 19/24] isErrorWithMessage addition --- examples/cookbook/app/network-requests/PhoneBook.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index 31e90deb6..caa460440 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -5,7 +5,6 @@ import ContactsList from './components/ContactsList'; import FavoritesList from './components/FavoritesList'; import getAllContacts from './api/getAllContacts'; import getAllFavorites from './api/getAllFavorites'; -import { isAxiosError } from 'axios'; export default () => { const [usersData, setUsersData] = useState([]); @@ -26,8 +25,7 @@ export default () => { try { await Promise.all([_getAllContacts(), _getAllFavorites()]); } catch (e) { - const isKnownError = e instanceof Error || isAxiosError(e); - const message = isKnownError ? e.message : 'Something went wrong'; + const message = isErrorWithMessage(e) ? e.message : 'Something went wrong'; setError(message); } }; @@ -46,3 +44,9 @@ export default () => { ); }; + +const isErrorWithMessage = ( + e: unknown, +): e is { + message: string; +} => typeof e === 'object' && e !== null && 'foo' in e; From d348f72f5ff77f8d36e5d71a911e0fa93bd1263a Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 21:12:56 +0200 Subject: [PATCH 20/24] isErrorWithMessage addition --- examples/cookbook/app/network-requests/PhoneBook.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/app/network-requests/PhoneBook.tsx b/examples/cookbook/app/network-requests/PhoneBook.tsx index caa460440..fe25520da 100644 --- a/examples/cookbook/app/network-requests/PhoneBook.tsx +++ b/examples/cookbook/app/network-requests/PhoneBook.tsx @@ -49,4 +49,4 @@ const isErrorWithMessage = ( e: unknown, ): e is { message: string; -} => typeof e === 'object' && e !== null && 'foo' in e; +} => typeof e === 'object' && e !== null && 'message' in e; From 29eff897d39ef1be8b87111ea4d876e3b6b6db03 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 21:14:59 +0200 Subject: [PATCH 21/24] jest setTimeout 8s --- .../cookbook/app/network-requests/__tests__/PhoneBook.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 5cd9b43c8..64bc96424 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,7 +6,7 @@ import { User } from '../types'; jest.mock('axios'); -jest.setTimeout(10000); +jest.setTimeout(8000); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ From 08a1e5cbe71ae17b501d5714c4edfcb460d4a28b Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 21:16:54 +0200 Subject: [PATCH 22/24] useFakeTimers --- .../cookbook/app/network-requests/__tests__/PhoneBook.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 64bc96424..9702367eb 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,7 +6,8 @@ import { User } from '../types'; jest.mock('axios'); -jest.setTimeout(8000); +// jest.setTimeout(10000); +jest.useFakeTimers(); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ From dfaff9e9e3e268fb5127ec38469a215245c119ed Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 21:20:34 +0200 Subject: [PATCH 23/24] run with --detectOpenHandles --- .github/workflows/example-apps.yml | 2 +- .../cookbook/app/network-requests/__tests__/PhoneBook.test.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/example-apps.yml b/.github/workflows/example-apps.yml index 02328a9e4..aec32791d 100644 --- a/.github/workflows/example-apps.yml +++ b/.github/workflows/example-apps.yml @@ -32,4 +32,4 @@ jobs: run: yarn --cwd examples/${{ matrix.example }} typecheck - name: Test - run: yarn --cwd examples/${{ matrix.example }} test + run: yarn --cwd examples/${{ matrix.example }} test --detectOpenHandles diff --git a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx index 9702367eb..5cd9b43c8 100644 --- a/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx +++ b/examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx @@ -6,8 +6,7 @@ import { User } from '../types'; jest.mock('axios'); -// jest.setTimeout(10000); -jest.useFakeTimers(); +jest.setTimeout(10000); describe('PhoneBook', () => { it('fetches contacts successfully and renders in list', async () => { (global.fetch as jest.Mock).mockResolvedValueOnce({ From d6d2f88aa2e46cbe7310a3f25e3fd5bc5bc084f5 Mon Sep 17 00:00:00 2001 From: vanGalilea Date: Tue, 27 Aug 2024 21:23:24 +0200 Subject: [PATCH 24/24] reset GH action --- .github/workflows/example-apps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/example-apps.yml b/.github/workflows/example-apps.yml index aec32791d..02328a9e4 100644 --- a/.github/workflows/example-apps.yml +++ b/.github/workflows/example-apps.yml @@ -32,4 +32,4 @@ jobs: run: yarn --cwd examples/${{ matrix.example }} typecheck - name: Test - run: yarn --cwd examples/${{ matrix.example }} test --detectOpenHandles + run: yarn --cwd examples/${{ matrix.example }} test