From 61b3e583b62b323d7864f4a6b6d34edd314c1ce9 Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Thu, 26 Dec 2024 03:01:50 +0100 Subject: [PATCH 1/7] feat: refactor ProductListScreen and improve test utilities - Implement `useBasket` hook to simplify basket state management. - Add `useNavigationHandlers` hook for cleaner navigation logic. - Introduce `ErrorState` and `LoadingState` components for better state handling. - Update tests to match refactored structure. - Add `renderWithProvidersAndNavigation` utility to support navigation and Redux in tests. --- .eslintrc.json | 5 +- .../utils/renderInProvidersAndNavigation.js | 27 +++++++ babel.config.js | 3 + jest.config.js | 3 + src/Navigator/Navigator.test.js | 4 +- .../CheckoutCard/CheckoutCard.test.js | 2 +- .../molecules/ProductCard/ProductCard.test.js | 2 +- .../molecules/Toggle/Toggle.test.js | 2 +- .../CheckoutList/CheckoutList.test.js | 2 +- .../organisms/ProductList/ProductList.test.js | 2 +- .../templates/BaseScreen/BaseScreen.test.js | 2 +- .../RedirectWithAnimation.test.js | 6 +- .../templates/RedirectWithAnimation/index.js | 2 +- src/hooks/index.js | 3 + .../{ => useBackHandler}/useBackHandler.js | 0 src/hooks/useBasket/useBasket.js | 12 +++ .../useNavigationHandlers.js | 24 ++++++ .../CheckoutScreen/CheckoutScreen.test.js | 24 ++++-- src/screens/CheckoutScreen/index.js | 15 ++-- .../PaymentForm/PaymentForm.style.js | 0 .../PaymentForm/PaymentForm.test.js | 4 +- .../{components => }/PaymentForm/index.js | 0 .../PaymentScreen/PaymentScreen.test.js | 32 +++++--- src/screens/PaymentScreen/index.js | 24 ++---- .../ErrorState/ErrorState.style.js | 13 ++++ .../ProductListScreen/ErrorState/index.js | 25 ++++++ .../ProductListScreen/LoadingState/index.js | 17 ++++ .../ProductListScreen.style.js | 3 +- .../ProductListScreen.test.js | 56 +++++++------- src/screens/ProductListScreen/index.js | 77 +++++++------------ .../SuccessScreen/SuccessScreen.test.js | 2 +- 31 files changed, 256 insertions(+), 137 deletions(-) create mode 100644 __tests__/utils/renderInProvidersAndNavigation.js create mode 100644 src/hooks/index.js rename src/hooks/{ => useBackHandler}/useBackHandler.js (100%) create mode 100644 src/hooks/useBasket/useBasket.js create mode 100644 src/hooks/useNavigationHandlers/useNavigationHandlers.js rename src/screens/PaymentScreen/{components => }/PaymentForm/PaymentForm.style.js (100%) rename src/screens/PaymentScreen/{components => }/PaymentForm/PaymentForm.test.js (90%) rename src/screens/PaymentScreen/{components => }/PaymentForm/index.js (100%) create mode 100644 src/screens/ProductListScreen/ErrorState/ErrorState.style.js create mode 100644 src/screens/ProductListScreen/ErrorState/index.js create mode 100644 src/screens/ProductListScreen/LoadingState/index.js diff --git a/.eslintrc.json b/.eslintrc.json index ec7959f..94ebea6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,10 +14,13 @@ ["@components", "./src/components"], ["@constants", "./src/constants"], ["@context", "./src/context"], + ["@hooks", "./src/hooks"], ["@screens", "./src/screens"], ["@redux", "./src/redux"], ["@utils", "./src/utils"], - ["@validate", "./src/validate"] + ["@validate", "./src/validate"], + ["@testUtils", "./__tests__/utils"], + ["@mocks", "./__tests__/mocks"] ], "extensions": [".js", ".jsx", ".json"] } diff --git a/__tests__/utils/renderInProvidersAndNavigation.js b/__tests__/utils/renderInProvidersAndNavigation.js new file mode 100644 index 0000000..598b0f6 --- /dev/null +++ b/__tests__/utils/renderInProvidersAndNavigation.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { Provider } from 'react-redux'; +import { setupApiStore } from './testUtil'; +import { apiSlice } from '../../src/redux/api/apiSlice'; +import basketSlice from '../../src/redux/slices/basketSlice'; + +const renderWithProvidersAndNavigation = ( + ui, + { + initialState = { basket: { items: [] } }, // Default Redux initial state + navigationOptions = {}, // NavigationContainer options + } = {}, +) => { + const { store } = setupApiStore(apiSlice, { basket: basketSlice }, initialState); + + const Wrapper = ({ children }) => ( + + {children} + + ); + + return render(ui, { wrapper: Wrapper }); +}; + +export default renderWithProvidersAndNavigation; diff --git a/babel.config.js b/babel.config.js index 3d0c8f1..8a80bf3 100644 --- a/babel.config.js +++ b/babel.config.js @@ -18,10 +18,13 @@ module.exports = function (api) { '@components': './src/components', '@constants': './src/constants', '@context': './src/context', + '@hooks': './src/hooks', '@redux': './src/redux', '@screens': './src/screens', '@utils': './src/utils', '@validate': './src/validate', + '@testUtils': './__tests__/utils', + '@mocks': './__tests__/mocks', }, }, ], diff --git a/jest.config.js b/jest.config.js index bfc6c36..00b1f30 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,10 +31,13 @@ module.exports = { '^@components/(.*)$': '/src/components/$1', '^@constants/(.*)$': '/src/constants/$1', '^@context/(.*)$': '/src/context/$1', + '^@hooks/(.*)$': '/src/hooks/$1', '^@redux/(.*)$': '/src/redux/$1', '^@screens/(.*)$': '/src/screens/$1', '^@utils/(.*)$': '/src/utils/$1', '^@validate/(.*)$': '/src/validate/$1', + '^@testUtils/(.*)$': '/__tests__/utils/$1', + '^@mocks/(.*)$': '/__tests__/mocks/$1', }, coverageReporters: ['json', 'json-summary', 'text', 'lcov'], }; diff --git a/src/Navigator/Navigator.test.js b/src/Navigator/Navigator.test.js index e98b69e..ab1aeeb 100644 --- a/src/Navigator/Navigator.test.js +++ b/src/Navigator/Navigator.test.js @@ -1,8 +1,8 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import renderInProvider from '@testUtils/renderInProvider'; +import { sampleBasket } from '@mocks/handlers'; import Navigator from './Navigator'; -import renderInProvider from '../../__tests__/utils/renderInProvider'; -import { sampleBasket } from '../../__tests__/mocks/handlers'; import { ThemeProvider } from '../context/ThemeContext'; // Import ThemeProvider const initialState = { basket: { items: sampleBasket } }; diff --git a/src/components/molecules/CheckoutCard/CheckoutCard.test.js b/src/components/molecules/CheckoutCard/CheckoutCard.test.js index 84122f8..628305f 100644 --- a/src/components/molecules/CheckoutCard/CheckoutCard.test.js +++ b/src/components/molecules/CheckoutCard/CheckoutCard.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; import { showToast } from '@utils'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; import CheckoutCard from '.'; jest.mock('@utils', () => ({ diff --git a/src/components/molecules/ProductCard/ProductCard.test.js b/src/components/molecules/ProductCard/ProductCard.test.js index 65f2dbd..5b37de8 100644 --- a/src/components/molecules/ProductCard/ProductCard.test.js +++ b/src/components/molecules/ProductCard/ProductCard.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; import ProductCard from '.'; describe('', () => { diff --git a/src/components/molecules/Toggle/Toggle.test.js b/src/components/molecules/Toggle/Toggle.test.js index e66c8bc..f8c78df 100644 --- a/src/components/molecules/Toggle/Toggle.test.js +++ b/src/components/molecules/Toggle/Toggle.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; // Import the utility function +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; // Import the utility function import Toggle from '.'; import { darkTheme, lightTheme } from '../../../constants/theme'; diff --git a/src/components/organisms/CheckoutList/CheckoutList.test.js b/src/components/organisms/CheckoutList/CheckoutList.test.js index 5006bc9..832d3ea 100644 --- a/src/components/organisms/CheckoutList/CheckoutList.test.js +++ b/src/components/organisms/CheckoutList/CheckoutList.test.js @@ -1,7 +1,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; import strings from '@constants/strings'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; import CheckoutList from '.'; describe('', () => { diff --git a/src/components/organisms/ProductList/ProductList.test.js b/src/components/organisms/ProductList/ProductList.test.js index 1206c76..779fdf3 100644 --- a/src/components/organisms/ProductList/ProductList.test.js +++ b/src/components/organisms/ProductList/ProductList.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; // Utility for ThemeProvider wrapping +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; // Utility for ThemeProvider wrapping import ProductList from '.'; describe('', () => { diff --git a/src/components/templates/BaseScreen/BaseScreen.test.js b/src/components/templates/BaseScreen/BaseScreen.test.js index 55b9014..74037c7 100644 --- a/src/components/templates/BaseScreen/BaseScreen.test.js +++ b/src/components/templates/BaseScreen/BaseScreen.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { screen } from '@testing-library/react-native'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; import Screen from '.'; import Text from '../../atoms/Text'; diff --git a/src/components/templates/RedirectWithAnimation/RedirectWithAnimation.test.js b/src/components/templates/RedirectWithAnimation/RedirectWithAnimation.test.js index 743b642..ecae1ec 100644 --- a/src/components/templates/RedirectWithAnimation/RedirectWithAnimation.test.js +++ b/src/components/templates/RedirectWithAnimation/RedirectWithAnimation.test.js @@ -1,11 +1,11 @@ import React from 'react'; import { screen, act, fireEvent } from '@testing-library/react-native'; import { strings } from '@constants'; -import { renderInThemeProvider } from '../../../../__tests__/utils/renderInThemeProvider'; +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; import RedirectWithAnimation from '.'; -import useBackHandler from '../../../hooks/useBackHandler'; +import { useBackHandler } from '../../../hooks'; -jest.mock('../../../hooks/useBackHandler', () => jest.fn()); +jest.mock('../../../hooks/useBackHandler/useBackHandler', () => jest.fn()); jest.useFakeTimers(); diff --git a/src/components/templates/RedirectWithAnimation/index.js b/src/components/templates/RedirectWithAnimation/index.js index 1230cf1..a0fb28e 100644 --- a/src/components/templates/RedirectWithAnimation/index.js +++ b/src/components/templates/RedirectWithAnimation/index.js @@ -7,7 +7,7 @@ import { spacing } from '@constants/theme'; import { strings } from '@constants'; import { Button, Text, LinearGradient } from '../../atoms'; import createStyles from './RedirectWithAnimation.style'; -import useBackHandler from '../../../hooks/useBackHandler'; +import useBackHandler from '../../../hooks/useBackHandler/useBackHandler'; const ANIMATION_DURATION = 1500; const REDIRECT_DURATION = 5000; diff --git a/src/hooks/index.js b/src/hooks/index.js new file mode 100644 index 0000000..67ac358 --- /dev/null +++ b/src/hooks/index.js @@ -0,0 +1,3 @@ +export { default as useBasket } from './useBasket/useBasket'; +export { default as useBackHandler } from './useBackHandler/useBackHandler'; +export { default as useNavigationHandlers } from './useNavigationHandlers/useNavigationHandlers'; diff --git a/src/hooks/useBackHandler.js b/src/hooks/useBackHandler/useBackHandler.js similarity index 100% rename from src/hooks/useBackHandler.js rename to src/hooks/useBackHandler/useBackHandler.js diff --git a/src/hooks/useBasket/useBasket.js b/src/hooks/useBasket/useBasket.js new file mode 100644 index 0000000..cca95ea --- /dev/null +++ b/src/hooks/useBasket/useBasket.js @@ -0,0 +1,12 @@ +import { useSelector } from 'react-redux'; +import { selectBasketItems, selectTotalItemCount, selectTotalPrice } from '@redux/selectors/basketSelector'; + +const useBasket = () => { + const basketItems = useSelector(selectBasketItems); + const totalItemCount = useSelector(selectTotalItemCount); + const totalPrice = useSelector(selectTotalPrice); + + return { basketItems, totalItemCount, totalPrice }; +}; + +export default useBasket; diff --git a/src/hooks/useNavigationHandlers/useNavigationHandlers.js b/src/hooks/useNavigationHandlers/useNavigationHandlers.js new file mode 100644 index 0000000..90034b9 --- /dev/null +++ b/src/hooks/useNavigationHandlers/useNavigationHandlers.js @@ -0,0 +1,24 @@ +import { useNavigation } from '@react-navigation/native'; +import { strings } from '@constants'; + +export default () => { + const navigation = useNavigation(); + + const navigateToCheckout = () => { + navigation.navigate(strings.screens.checkout); + }; + + const navigateToPayment = () => { + navigation.navigate(strings.screens.payment); + }; + + const navigateToError = (errorMessage) => { + navigation.navigate(strings.screens.error, { errorMessage }); + }; + + const navigateToSuccess = () => { + navigation.navigate(strings.screens.success); + }; + + return { navigateToCheckout, navigateToPayment, navigateToError, navigateToSuccess }; +}; diff --git a/src/screens/CheckoutScreen/CheckoutScreen.test.js b/src/screens/CheckoutScreen/CheckoutScreen.test.js index f1dd9a0..ac365f7 100644 --- a/src/screens/CheckoutScreen/CheckoutScreen.test.js +++ b/src/screens/CheckoutScreen/CheckoutScreen.test.js @@ -1,25 +1,37 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import renderWithProvidersAndNavigation from '@testUtils/renderInProvidersAndNavigation'; +import { sampleBasket } from '@mocks/handlers'; +import mockNavigation from '@mocks/navigation'; import CheckoutScreen from '.'; -import renderInProvider from '../../../__tests__/utils/renderInProvider'; -import { sampleBasket } from '../../../__tests__/mocks/handlers'; -import mockNavigation from '../../../__tests__/mocks/navigation'; const initialState = { basket: { items: sampleBasket } }; +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +const navigateMock = jest.fn(); + +useNavigation.mockReturnValue({ + navigate: navigateMock, +}); + describe('CheckoutScreen', () => { it('should render CheckoutScreen correctly', () => { - renderInProvider(, { initialState }); + renderWithProvidersAndNavigation(, { initialState }); expect(screen.getByText('Mens Casual Premium Slim Fit T-Shirts')).toBeTruthy(); // expect(screen.getByText('Remove Item')).toBeTruthy(); // expect(screen.getByText('ORDER')).toBeTruthy(); + (14 items) }); it('should match the snapshot', () => { - renderInProvider(); + renderWithProvidersAndNavigation(); expect(screen.toJSON()).toMatchSnapshot(); }); it('should update credit card input value', () => { - renderInProvider(, { initialState }); + renderWithProvidersAndNavigation(, { initialState }); // const creditCardInput = screen.getByLabelText('Credit Card'); // const creditCardInput = screen.getByPlaceholderText('Enter your credit card number'); diff --git a/src/screens/CheckoutScreen/index.js b/src/screens/CheckoutScreen/index.js index ebf7782..db35b7b 100644 --- a/src/screens/CheckoutScreen/index.js +++ b/src/screens/CheckoutScreen/index.js @@ -1,7 +1,7 @@ import React from 'react'; import { View } from 'react-native'; import { useTheme } from 'react-native-paper'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useForm, Controller } from 'react-hook-form'; import { Button, Text, TextInput } from '@components/atoms'; import { Input, ActivityOverlay } from '@components/molecules'; @@ -9,15 +9,16 @@ import { CheckoutList } from '@components/organisms'; import { BaseScreen } from '@components/templates'; import { removeItemFromBasket, updateItemQuantity, setDiscount } from '@redux/slices/basketSlice'; import { useValidatePromoCodeMutation } from '@redux/api/apiSlice'; -import { selectBasketItems, selectTotalItemCount, selectTotalPrice } from '@redux/selectors/basketSelector'; import { validateBasket } from '@validate'; import { showToast } from '@utils'; import { toastMessages, strings } from '@constants'; import styles from './CheckoutScreen.style'; +import { useBasket, useNavigationHandlers } from '../../hooks'; -const CheckoutScreen = ({ navigation }) => { +const CheckoutScreen = () => { const { colors } = useTheme(); const dispatch = useDispatch(); + const { navigateToPayment } = useNavigationHandlers(); const { control, @@ -30,11 +31,7 @@ const CheckoutScreen = ({ navigation }) => { }, }); - const { basketItems, totalItemCount, totalPrice } = useSelector((state) => ({ - basketItems: selectBasketItems(state), - totalItemCount: selectTotalItemCount(state), - totalPrice: selectTotalPrice(state), - })); + const { basketItems, totalItemCount, totalPrice } = useBasket(); const isBasketEmpty = basketItems.length === 0; @@ -49,7 +46,7 @@ const CheckoutScreen = ({ navigation }) => { showToast(toastMessages.basket.empty); return; } - navigation.navigate(strings.screens.payment); + navigateToPayment(); }; const onApplyPromoCode = async (data) => { diff --git a/src/screens/PaymentScreen/components/PaymentForm/PaymentForm.style.js b/src/screens/PaymentScreen/PaymentForm/PaymentForm.style.js similarity index 100% rename from src/screens/PaymentScreen/components/PaymentForm/PaymentForm.style.js rename to src/screens/PaymentScreen/PaymentForm/PaymentForm.style.js diff --git a/src/screens/PaymentScreen/components/PaymentForm/PaymentForm.test.js b/src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js similarity index 90% rename from src/screens/PaymentScreen/components/PaymentForm/PaymentForm.test.js rename to src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js index 87b9fde..d981cac 100644 --- a/src/screens/PaymentScreen/components/PaymentForm/PaymentForm.test.js +++ b/src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js @@ -2,8 +2,8 @@ import React from 'react'; import { screen } from '@testing-library/react-native'; import { useForm, FormProvider } from 'react-hook-form'; import { strings } from '@constants'; -import PaymentForm from '.'; -import { renderInThemeProvider } from '../../../../../__tests__/utils/renderInThemeProvider'; +import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; +import PaymentForm from './index'; jest.mock('@utils', () => ({ paymentUtils: { diff --git a/src/screens/PaymentScreen/components/PaymentForm/index.js b/src/screens/PaymentScreen/PaymentForm/index.js similarity index 100% rename from src/screens/PaymentScreen/components/PaymentForm/index.js rename to src/screens/PaymentScreen/PaymentForm/index.js diff --git a/src/screens/PaymentScreen/PaymentScreen.test.js b/src/screens/PaymentScreen/PaymentScreen.test.js index 8ab3c36..741f03c 100644 --- a/src/screens/PaymentScreen/PaymentScreen.test.js +++ b/src/screens/PaymentScreen/PaymentScreen.test.js @@ -1,9 +1,21 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react-native'; import { strings } from '@constants'; +import { useNavigation } from '@react-navigation/native'; +import renderWithProvidersAndNavigation from '@testUtils/renderInProvidersAndNavigation'; +import { sampleBasket } from '@mocks/handlers'; import PaymentScreen from '.'; -import renderInProvider from '../../../__tests__/utils/renderInProvider'; -import { sampleBasket } from '../../../__tests__/mocks/handlers'; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +const navigateMock = jest.fn(); + +useNavigation.mockReturnValue({ + navigate: navigateMock, +}); const initialState = { basket: { @@ -19,7 +31,7 @@ describe('', () => { }); it('displays the correct basket summary', () => { - renderInProvider(, { + renderWithProvidersAndNavigation(, { initialState, }); const itemCountText = screen.getByText('Items in the basket: 14'); @@ -32,7 +44,7 @@ describe('', () => { }); it('renders PaymentForm', () => { - renderInProvider(, { + renderWithProvidersAndNavigation(, { initialState, }); @@ -44,7 +56,7 @@ describe('', () => { it('disables the order button when basket is empty', () => { const mockOnPress = jest.fn(); - renderInProvider(, { + renderWithProvidersAndNavigation(, { initialState, }); @@ -54,7 +66,7 @@ describe('', () => { expect(mockOnPress).not.toHaveBeenCalled(); }); it('validates credit card input', async () => { - renderInProvider(, { + renderWithProvidersAndNavigation(, { initialState, }); @@ -69,7 +81,7 @@ describe('', () => { }); }); it('show feedback messages about required fields when pressed pay and order button without filling inputs', async () => { - renderInProvider(, { + renderWithProvidersAndNavigation(, { initialState, }); @@ -89,7 +101,7 @@ describe('', () => { expect(screen.getAllByText(strings.payment.invalidExpirationDate)[0]).toBeTruthy(); }); it('show feedback messages about required fields when pressed pay and order button without filling inputs', async () => { - renderInProvider(, { + renderWithProvidersAndNavigation(, { initialState, }); @@ -101,6 +113,8 @@ describe('', () => { }); expect(screen.getAllByText(strings.payment.cvvRequired)[0]).toBeTruthy(); expect(screen.getAllByText(strings.payment.expirationDateRequired)[0]).toBeTruthy(); - expect(screen.getAllByText(strings.payment.creditCardRequired)[0]).toBeTruthy(); + await waitFor(() => { + expect(screen.getAllByText(strings.payment.creditCardRequired)[0]).toBeTruthy(); + }); }); }); diff --git a/src/screens/PaymentScreen/index.js b/src/screens/PaymentScreen/index.js index 62cc21e..62b30bd 100644 --- a/src/screens/PaymentScreen/index.js +++ b/src/screens/PaymentScreen/index.js @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useForm } from 'react-hook-form'; import { useTheme } from 'react-native-paper'; import { Button } from '@components/atoms'; @@ -8,12 +8,12 @@ import { ActivityOverlay, BasketSummary } from '@components/molecules'; import { BaseScreen } from '@components/templates'; import { clearBasket, clearDiscount } from '@redux/slices/basketSlice'; import { usePlaceOrderMutation } from '@redux/api/apiSlice'; -import { selectBasketItems, selectTotalItemCount, selectTotalPrice } from '@redux/selectors/basketSelector'; import { showToast, paymentUtils } from '@utils'; import { toastMessages, strings } from '@constants'; import { checkCreditCardWithCardValidator, validateBasket } from '@validate'; +import { useBasket, useNavigationHandlers } from '@hooks'; import styles from './PaymentScreen.style'; -import PaymentForm from './components/PaymentForm'; +import PaymentForm from './PaymentForm'; const DEFAULT_FORM_VALUES = { creditCardNumber: '', @@ -22,9 +22,9 @@ const DEFAULT_FORM_VALUES = { cvv: '', }; -const PaymentScreen = ({ navigation }) => { +const PaymentScreen = () => { const dispatch = useDispatch(); - const { navigate } = navigation; + const { navigateToSuccess, navigateToError } = useNavigationHandlers(); const { colors } = useTheme(); const { control, @@ -36,23 +36,11 @@ const PaymentScreen = ({ navigation }) => { defaultValues: DEFAULT_FORM_VALUES, }); - const { basketItems, totalItemCount, totalPrice } = useSelector((state) => ({ - basketItems: selectBasketItems(state), - totalItemCount: selectTotalItemCount(state), - totalPrice: selectTotalPrice(state), - })); + const { basketItems, totalItemCount, totalPrice } = useBasket(); const [placeOrder, { isLoading }] = usePlaceOrderMutation(); const isOrderDisabled = basketItems.length === 0 || isLoading; - const navigateToError = (errorMessage) => { - navigate(strings.screens.error, { errorMessage }); - }; - - const navigateToSuccess = () => { - navigate(strings.screens.success); - }; - const isCreditCardValid = useMemo(() => checkCreditCardWithCardValidator(watch('creditCardNumber')), [watch]); const handleOrderError = (err) => { diff --git a/src/screens/ProductListScreen/ErrorState/ErrorState.style.js b/src/screens/ProductListScreen/ErrorState/ErrorState.style.js new file mode 100644 index 0000000..d5294ed --- /dev/null +++ b/src/screens/ProductListScreen/ErrorState/ErrorState.style.js @@ -0,0 +1,13 @@ +import { StyleSheet } from 'react-native'; + +export default StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + padding: 16, + }, + errorText: { + marginBottom: 8, + textAlign: 'center', + }, +}); diff --git a/src/screens/ProductListScreen/ErrorState/index.js b/src/screens/ProductListScreen/ErrorState/index.js new file mode 100644 index 0000000..6099271 --- /dev/null +++ b/src/screens/ProductListScreen/ErrorState/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Button, HelperText } from '@components/atoms'; +import PropTypes from 'prop-types'; +import styles from './ErrorState.style'; + +const ErrorState = ({ errorMessage, onRetry }) => { + return ( + + + {errorMessage} + + + + ); +}; + +ErrorState.propTypes = { + errorMessage: PropTypes.string.isRequired, + onRetry: PropTypes.func.isRequired, +}; + +export default ErrorState; diff --git a/src/screens/ProductListScreen/LoadingState/index.js b/src/screens/ProductListScreen/LoadingState/index.js new file mode 100644 index 0000000..1c6f21e --- /dev/null +++ b/src/screens/ProductListScreen/LoadingState/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { View } from 'react-native'; +import { ActivityIndicator } from '@components/atoms'; +import PropTypes from 'prop-types'; +import globalStyles from '../../../global.style'; + +const LoadingState = ({ color }) => ( + + + +); + +export default LoadingState; + +LoadingState.propTypes = { + color: PropTypes.string, +}; diff --git a/src/screens/ProductListScreen/ProductListScreen.style.js b/src/screens/ProductListScreen/ProductListScreen.style.js index 83178f8..2770a77 100644 --- a/src/screens/ProductListScreen/ProductListScreen.style.js +++ b/src/screens/ProductListScreen/ProductListScreen.style.js @@ -9,6 +9,5 @@ export default StyleSheet.create({ flexGrow: 1, justifyContent: 'center', }, - errorText: { alignSelf: 'center' }, - avtivityIndicator: { marginBottom: 72 }, + activityIndicator: { marginBottom: 72 }, }); diff --git a/src/screens/ProductListScreen/ProductListScreen.test.js b/src/screens/ProductListScreen/ProductListScreen.test.js index 59e5a79..f254212 100644 --- a/src/screens/ProductListScreen/ProductListScreen.test.js +++ b/src/screens/ProductListScreen/ProductListScreen.test.js @@ -1,46 +1,46 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { useNavigation } from '@react-navigation/native'; +import renderWithProvidersAndNavigation from '@testUtils/renderInProvidersAndNavigation'; +import { sampleBasket, sampleResponse } from '@mocks/handlers'; import ProductListScreen from '.'; -import renderInProvider from '../../../__tests__/utils/renderInProvider'; -import { sampleBasket, sampleResponse } from '../../../__tests__/mocks/handlers'; - -const mockOnPress = jest.fn(); -const mockNavigation = { - navigate: jest.fn(), - goBack: jest.fn(), - setParams: jest.fn(), -}; -const initialState = { items: { items: sampleResponse }, basket: { items: sampleBasket } }; +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +const navigateMock = jest.fn(); + +useNavigation.mockReturnValue({ + navigate: navigateMock, +}); + +const initialState = { + items: { items: sampleResponse }, + basket: { items: sampleBasket }, +}; describe('ProductListScreen', () => { it('should render the first two items', async () => { - renderInProvider(); + renderWithProvidersAndNavigation(, { initialState }); - // expect(screen.getByText('Items in the basket: 0')).toBeTruthy(); - - // Check that product titles are rendered await waitFor(() => { expect(screen.getByText('Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops')).toBeTruthy(); }); + expect(screen.getByText('Mens Casual Premium Slim Fit T-Shirts ')).toBeTruthy(); }); - it('should call onPress when the checkout button is pressed', async () => { - renderInProvider(, { initialState }); - let button; - await waitFor(() => { - button = screen.getByText('CHECKOUT'); - }); - fireEvent.press(button); + it('should call navigate when the checkout button is pressed', async () => { + renderWithProvidersAndNavigation(, { initialState }); - expect(mockNavigation.navigate).toHaveBeenCalledTimes(1); + const checkoutButton = await screen.findByText('CHECKOUT'); + fireEvent.press(checkoutButton); - expect(mockNavigation.navigate).toHaveBeenCalledWith('Checkout'); + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledTimes(1); + }); + expect(navigateMock).toHaveBeenCalledWith('Checkout'); // Replace 'Checkout' with the actual route name }); - /* // mock the state to simulate a change to CheckoutScreen - it('should render the CheckoutScreen when the state changes', () => { - // mock the `useState` to return the `CHECKOUT_SCREEN` state - // simulate the flow to change screens - }); */ }); diff --git a/src/screens/ProductListScreen/index.js b/src/screens/ProductListScreen/index.js index 1d74a80..67a707a 100644 --- a/src/screens/ProductListScreen/index.js +++ b/src/screens/ProductListScreen/index.js @@ -1,38 +1,38 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useTheme } from 'react-native-paper'; -import { ActivityIndicator, Button, HelperText } from '@components/atoms'; +import { Button } from '@components/atoms'; import { BasketSummary } from '@components/molecules'; import { ProductList } from '@components/organisms'; import { BaseScreen } from '@components/templates'; import { addItemToBasket } from '@redux/slices/basketSlice'; import { useGetProductsQuery } from '@redux/api/apiSlice'; -import { selectTotalItemCount, selectBasketItems, selectTotalPrice } from '@redux/selectors/basketSelector'; import { validateBasket } from '@validate'; import showToast from '@utils/showToast'; import { toastMessages, strings } from '@constants'; +import { useBasket, useNavigationHandlers } from '@hooks'; import styles from './ProductListScreen.style'; -import globalStyles from '../../global.style'; +import LoadingState from './LoadingState'; +import ErrorState from './ErrorState'; -const ProductListScreen = ({ navigation }) => { +const ProductListScreen = () => { const { colors } = useTheme(); const dispatch = useDispatch(); - const { basketItems, totalItemCount, totalPrice } = useSelector((state) => ({ - basketItems: selectBasketItems(state), - totalItemCount: selectTotalItemCount(state), - totalPrice: selectTotalPrice(state), - })); + const { basketItems, totalItemCount, totalPrice } = useBasket(); + const { navigateToCheckout } = useNavigationHandlers(); const { data: products, error, isLoading, refetch } = useGetProductsQuery(); const basketItemsMap = useMemo(() => new Map(basketItems.map((item) => [item.id, item])), [basketItems]); + const isCheckoutButtonVisible = !error && !isLoading; + const isBasketSummaryVisible = !error && !isLoading; const onCheckoutPress = () => { if (!validateBasket(basketItems)) { showToast(toastMessages.promo.error); return; } - navigation.navigate(strings.screens.checkout); + navigateToCheckout(); }; const onAddToBasket = (item) => { @@ -44,47 +44,26 @@ const ProductListScreen = ({ navigation }) => { dispatch(addItemToBasket(item)); }; + if (isLoading) return ; + if (error) return ; + return ( - {isLoading ? ( - - + {isBasketSummaryVisible && } + {!error && ( + + )} + {isCheckoutButtonVisible && ( + + - ) : ( - <> - {error ? ( - - - {strings.productList.errorLoading} - - - - ) : ( - - )} - {!error && ( - - )} - {!error && ( - - - - )} - )} ); diff --git a/src/screens/SuccessScreen/SuccessScreen.test.js b/src/screens/SuccessScreen/SuccessScreen.test.js index 31afb2d..f7a4e49 100644 --- a/src/screens/SuccessScreen/SuccessScreen.test.js +++ b/src/screens/SuccessScreen/SuccessScreen.test.js @@ -1,8 +1,8 @@ import React from 'react'; import { screen } from '@testing-library/react-native'; import strings from '@constants/strings'; +import renderInNavigation from '@testUtils/renderInNavigation'; import SuccessScreen from '.'; -import renderInNavigation from '../../../__tests__/utils/renderInNavigation'; describe('SuccessScreen Component', () => { it('should render the success message', () => { From 354bf63289d3e2596b6da88e83599c27bf71ed51 Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Thu, 26 Dec 2024 14:54:00 +0100 Subject: [PATCH 2/7] test: enhance ProductListScreen and related unit tests - Add unit tests for `ErrorState` and `LoadingState` components - Implement tests for basket functionality, including: - Enforcing a limit of 5 items per product in the basket. - Validating "Add to basket" button removal and limit message display. - Add navigation tests to ensure proper redirection to the Checkout screen. --- .../molecules}/ErrorState/ErrorState.style.js | 0 .../molecules/ErrorState/ErrorState.test.js | 26 +++ .../__snapshots__/ErrorState.test.js.snap | 190 ++++++++++++++++++ .../molecules}/ErrorState/index.js | 0 .../LoadingState/LoadingState.test.js | 10 + .../__snapshots__/LoadingState.test.js.snap | 23 +++ .../molecules}/LoadingState/index.js | 2 +- src/components/molecules/ProductCard/index.js | 3 +- src/components/molecules/index.js | 2 + src/constants/strings.js | 3 +- src/constants/toastMessages.js | 2 +- .../PaymentScreen/PaymentScreen.test.js | 8 +- src/screens/PaymentScreen/index.js | 2 +- .../ProductListScreen.test.js | 161 +++++++++++++-- src/screens/ProductListScreen/index.js | 19 +- 15 files changed, 414 insertions(+), 37 deletions(-) rename src/{screens/ProductListScreen => components/molecules}/ErrorState/ErrorState.style.js (100%) create mode 100644 src/components/molecules/ErrorState/ErrorState.test.js create mode 100644 src/components/molecules/ErrorState/__snapshots__/ErrorState.test.js.snap rename src/{screens/ProductListScreen => components/molecules}/ErrorState/index.js (100%) create mode 100644 src/components/molecules/LoadingState/LoadingState.test.js create mode 100644 src/components/molecules/LoadingState/__snapshots__/LoadingState.test.js.snap rename src/{screens/ProductListScreen => components/molecules}/LoadingState/index.js (81%) diff --git a/src/screens/ProductListScreen/ErrorState/ErrorState.style.js b/src/components/molecules/ErrorState/ErrorState.style.js similarity index 100% rename from src/screens/ProductListScreen/ErrorState/ErrorState.style.js rename to src/components/molecules/ErrorState/ErrorState.style.js diff --git a/src/components/molecules/ErrorState/ErrorState.test.js b/src/components/molecules/ErrorState/ErrorState.test.js new file mode 100644 index 0000000..92e4f97 --- /dev/null +++ b/src/components/molecules/ErrorState/ErrorState.test.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react-native'; +import ErrorState from '.'; + +describe('ErrorState Component', () => { + const mockRetry = jest.fn(); + + it('renders the error message', () => { + render(); + expect(screen.getByText('Test error message')).toBeTruthy(); + }); + + it('calls the retry function when the Retry button is pressed', () => { + render(); + + const retryButton = screen.getByText('Retry'); + fireEvent.press(retryButton); + + expect(mockRetry).toHaveBeenCalledTimes(1); + }); + + it('matches the snapshot', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/components/molecules/ErrorState/__snapshots__/ErrorState.test.js.snap b/src/components/molecules/ErrorState/__snapshots__/ErrorState.test.js.snap new file mode 100644 index 0000000..7142802 --- /dev/null +++ b/src/components/molecules/ErrorState/__snapshots__/ErrorState.test.js.snap @@ -0,0 +1,190 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ErrorState Component matches the snapshot 1`] = ` + + + Snapshot error + + + + + + + Retry + + + + + + +`; diff --git a/src/screens/ProductListScreen/ErrorState/index.js b/src/components/molecules/ErrorState/index.js similarity index 100% rename from src/screens/ProductListScreen/ErrorState/index.js rename to src/components/molecules/ErrorState/index.js diff --git a/src/components/molecules/LoadingState/LoadingState.test.js b/src/components/molecules/LoadingState/LoadingState.test.js new file mode 100644 index 0000000..20c4bbc --- /dev/null +++ b/src/components/molecules/LoadingState/LoadingState.test.js @@ -0,0 +1,10 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import LoadingState from '.'; + +describe('LoadingState Component', () => { + it('matches the snapshot', () => { + const { toJSON } = render(); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/components/molecules/LoadingState/__snapshots__/LoadingState.test.js.snap b/src/components/molecules/LoadingState/__snapshots__/LoadingState.test.js.snap new file mode 100644 index 0000000..3bb12e4 --- /dev/null +++ b/src/components/molecules/LoadingState/__snapshots__/LoadingState.test.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoadingState Component matches the snapshot 1`] = ` + + + +`; diff --git a/src/screens/ProductListScreen/LoadingState/index.js b/src/components/molecules/LoadingState/index.js similarity index 81% rename from src/screens/ProductListScreen/LoadingState/index.js rename to src/components/molecules/LoadingState/index.js index 1c6f21e..77904ff 100644 --- a/src/screens/ProductListScreen/LoadingState/index.js +++ b/src/components/molecules/LoadingState/index.js @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import globalStyles from '../../../global.style'; const LoadingState = ({ color }) => ( - + ); diff --git a/src/components/molecules/ProductCard/index.js b/src/components/molecules/ProductCard/index.js index 45caf54..4eb7aa0 100644 --- a/src/components/molecules/ProductCard/index.js +++ b/src/components/molecules/ProductCard/index.js @@ -5,6 +5,7 @@ import { useTheme } from 'react-native-paper'; import { spacing } from '@constants/theme'; import { Card, Button, Text, HelperText } from '../../atoms'; import createStyles from './ProductCard.style'; +import { strings } from '../../../constants'; const leftCardMargin = { marginRight: 5 }; const rightCardMargin = { marginLeft: 5 }; @@ -35,7 +36,7 @@ const ProductCard = ({ product, onButtonPress, isMaxQuantityPerProductReached, i ) : ( - You reached the max quantity per product! + {strings.productList.limitReached} )} diff --git a/src/components/molecules/index.js b/src/components/molecules/index.js index f93054e..42ede1c 100644 --- a/src/components/molecules/index.js +++ b/src/components/molecules/index.js @@ -2,7 +2,9 @@ export { default as ActivityOverlay } from './ActivityOverlay'; export { default as BasketSummary } from './BasketSummary'; export { default as ControlledInput } from './ControlledInput'; export { default as CheckoutCard } from './CheckoutCard'; +export { default as ErrorState } from './ErrorState'; export { default as Header } from './Header'; export { default as Input } from './Input'; +export { default as LoadingState } from './LoadingState'; export { default as ProductCard } from './ProductCard'; export { default as Toggle } from './Toggle'; diff --git a/src/constants/strings.js b/src/constants/strings.js index 09b72f9..0f4bd03 100644 --- a/src/constants/strings.js +++ b/src/constants/strings.js @@ -22,13 +22,14 @@ export default { retry: 'Retry', checkout: 'CHECKOUT', order: 'ORDER', - payAndorder: 'PAY AND ORDER', + payAndOrder: 'PAY AND ORDER', gotoProducts: 'Go to Products', }, productList: { basketItemCount: 'Items in the basket: ', loading: 'Loading products...', errorLoading: 'Unable to load products. Please try again.', + limitReached: 'You reached the max quantity per product!', }, checkout: { total: 'Total:', diff --git a/src/constants/toastMessages.js b/src/constants/toastMessages.js index 69fc7ca..03c2f00 100644 --- a/src/constants/toastMessages.js +++ b/src/constants/toastMessages.js @@ -1,6 +1,6 @@ const messages = { basket: { - limitReached: { title: 'Limit Reached', msg: 'You can only add a maximum of 15 units per item.' }, + limitReached: { title: 'Limit Reached', msg: 'You can only add a maximum of 5 units per item.' }, empty: { title: 'Empty Basket', msg: 'Please add items to your basket before placing an order.' }, invalidQuantity: { title: 'Invalid Quantity', msg: 'Quantity must be between 1 and 5.' }, }, diff --git a/src/screens/PaymentScreen/PaymentScreen.test.js b/src/screens/PaymentScreen/PaymentScreen.test.js index 741f03c..a7b5fde 100644 --- a/src/screens/PaymentScreen/PaymentScreen.test.js +++ b/src/screens/PaymentScreen/PaymentScreen.test.js @@ -60,7 +60,7 @@ describe('', () => { initialState, }); - const orderButton = screen.getByText(strings.buttons.payAndorder); + const orderButton = screen.getByText(strings.buttons.payAndOrder); fireEvent.press(orderButton); expect(mockOnPress).not.toHaveBeenCalled(); @@ -73,7 +73,7 @@ describe('', () => { const cardNumberInput = screen.getAllByText(strings.payment.creditCardNumber)[0]; fireEvent.changeText(cardNumberInput, '1234'); - const orderButton = screen.getByText(strings.buttons.payAndorder); + const orderButton = screen.getByText(strings.buttons.payAndOrder); fireEvent.press(orderButton); await waitFor(() => { @@ -92,7 +92,7 @@ describe('', () => { const expirationInput = screen.getAllByText(strings.payment.expirationDate)[0]; fireEvent.changeText(expirationInput, '07'); - const orderButton = screen.getByText(strings.buttons.payAndorder); + const orderButton = screen.getByText(strings.buttons.payAndOrder); fireEvent.press(orderButton); await waitFor(() => { expect(screen.getAllByText(strings.payment.cardHolderMinLength)[0]).toBeTruthy(); @@ -105,7 +105,7 @@ describe('', () => { initialState, }); - const orderButton = screen.getByText(strings.buttons.payAndorder); + const orderButton = screen.getByText(strings.buttons.payAndOrder); fireEvent.press(orderButton); await waitFor(() => { diff --git a/src/screens/PaymentScreen/index.js b/src/screens/PaymentScreen/index.js index 62b30bd..91d8f93 100644 --- a/src/screens/PaymentScreen/index.js +++ b/src/screens/PaymentScreen/index.js @@ -79,7 +79,7 @@ const PaymentScreen = () => { diff --git a/src/screens/ProductListScreen/ProductListScreen.test.js b/src/screens/ProductListScreen/ProductListScreen.test.js index f254212..3eaf7af 100644 --- a/src/screens/ProductListScreen/ProductListScreen.test.js +++ b/src/screens/ProductListScreen/ProductListScreen.test.js @@ -1,8 +1,10 @@ import React from 'react'; -import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react-native'; import { useNavigation } from '@react-navigation/native'; import renderWithProvidersAndNavigation from '@testUtils/renderInProvidersAndNavigation'; import { sampleBasket, sampleResponse } from '@mocks/handlers'; +import { strings } from '@constants'; +import { useGetProductsQuery } from '@redux/api/apiSlice'; import ProductListScreen from '.'; jest.mock('@react-navigation/native', () => ({ @@ -10,30 +12,92 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: jest.fn(), })); +jest.mock('@redux/api/apiSlice', () => ({ + ...jest.requireActual('@redux/api/apiSlice'), + useGetProductsQuery: jest.fn(), // Mock the hook +})); const navigateMock = jest.fn(); useNavigation.mockReturnValue({ navigate: navigateMock, }); -const initialState = { - items: { items: sampleResponse }, - basket: { items: sampleBasket }, -}; +const anItem = sampleResponse[0]; describe('ProductListScreen', () => { - it('should render the first two items', async () => { - renderWithProvidersAndNavigation(, { initialState }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render the loading state when loading', () => { + useGetProductsQuery.mockReturnValue({ + data: [], + error: false, + isLoading: true, + }); - await waitFor(() => { - expect(screen.getByText('Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops')).toBeTruthy(); + renderWithProvidersAndNavigation(); + + // Ensure the loading indicator is visible and error message is not + expect(screen.getByTestId('loading-state')).toBeTruthy(); + expect(screen.queryByText(strings.productList.errorLoading)).toBeFalsy(); + }); + + it('should render the error state when there is an error', async () => { + const refetchMock = jest.fn(); + + useGetProductsQuery.mockReturnValue({ + data: [], + error: true, + isLoading: false, + refetch: refetchMock, }); + renderWithProvidersAndNavigation(); + + // Ensure the error message is visible and Loading state is not + expect(screen.getByText(strings.productList.errorLoading)).toBeTruthy(); + expect(screen.queryByText('loading-state')).toBeFalsy(); + }); + + it('should call refetch when Retry pressed in error state', async () => { + const refetchMock = jest.fn(); + + useGetProductsQuery.mockReturnValue({ + data: [], + error: true, + isLoading: false, + refetch: refetchMock, + }); + + renderWithProvidersAndNavigation(); + + const retryButton = screen.getByText('Retry'); + fireEvent.press(retryButton); + + expect(refetchMock).toHaveBeenCalledTimes(1); + }); + + it('should render the first two items from the product list', async () => { + useGetProductsQuery.mockReturnValue({ + data: sampleResponse, + error: false, + isLoading: false, + }); + renderWithProvidersAndNavigation(); + + await waitFor(() => { + expect(screen.getByText(anItem.title)).toBeTruthy(); + }); expect(screen.getByText('Mens Casual Premium Slim Fit T-Shirts ')).toBeTruthy(); }); - it('should call navigate when the checkout button is pressed', async () => { - renderWithProvidersAndNavigation(, { initialState }); + it('navigates to Checkout screen when checkout button is pressed', async () => { + useGetProductsQuery.mockReturnValue({ + data: sampleResponse, + error: false, + isLoading: false, + }); + renderWithProvidersAndNavigation(, { initialState: { basket: { items: sampleBasket } } }); const checkoutButton = await screen.findByText('CHECKOUT'); fireEvent.press(checkoutButton); @@ -41,6 +105,79 @@ describe('ProductListScreen', () => { await waitFor(() => { expect(navigateMock).toHaveBeenCalledTimes(1); }); - expect(navigateMock).toHaveBeenCalledWith('Checkout'); // Replace 'Checkout' with the actual route name + expect(navigateMock).toHaveBeenCalledWith('Checkout'); // Replace with the actual route name if necessary + }); + + it('should disable the checkout button when the basket is invalid', () => { + useGetProductsQuery.mockReturnValue({ + data: sampleResponse, + error: false, + isLoading: false, + }); + renderWithProvidersAndNavigation(); + + const checkoutButton = screen.getByText('CHECKOUT'); + fireEvent.press(checkoutButton); + expect(navigateMock).toHaveBeenCalledTimes(0); + }); + + it('should add a product to the basket and update totalItemCount and total price', async () => { + useGetProductsQuery.mockReturnValue({ + data: sampleResponse, + error: false, + isLoading: false, + }); + + renderWithProvidersAndNavigation(); + + // Initial totalItemCount + expect(screen.getByText('Items in the basket: 0')).toBeTruthy(); + + // Simulate adding the first product to the basket + const addToBasketButton = screen.getAllByText('Add to basket')[0]; + fireEvent.press(addToBasketButton); + + // Updated totalItemCount + await waitFor(() => { + expect(screen.getByText('Items in the basket: 1')).toBeTruthy(); + }); + expect(screen.getByText('Total: $109.95')).toBeTruthy(); + }); + it('should not allow adding the same product to the basket more than five times', async () => { + useGetProductsQuery.mockReturnValue({ + data: sampleResponse, + error: false, + isLoading: false, + }); + + renderWithProvidersAndNavigation(); + + // Get the first product's Card and its "Add to basket" button + const firstProductCard = screen.getAllByTestId('product-card')[0]; + const addToBasketButton = within(firstProductCard).getByText('Add to basket'); + + // Initial assertions + expect(screen.getByText('Items in the basket: 0')).toBeTruthy(); // Basket starts empty + expect(screen.queryByText(strings.productList.limitReached)).toBeFalsy(); + + // Add the same product to the basket five times + for (let i = 0; i < 5; i += 1) { + fireEvent.press(addToBasketButton); + } + + // Check total item count and total price updated + await waitFor(() => { + expect(screen.getByText('Items in the basket: 5')).toBeTruthy(); + }); + expect(screen.getByText('Total: $549.75')).toBeTruthy(); // Ensure total price is updated correctly + + // Ensure the "Add to basket" button is removed after reaching the limit + expect(within(firstProductCard).queryByText('Add to basket')).toBeFalsy(); + + // Ensure the limit message is displayed + expect(screen.getByText(strings.productList.limitReached)).toBeTruthy(); + + // TotalItemCount should still be 5, not updated further + expect(screen.getByText('Items in the basket: 5')).toBeTruthy(); }); }); diff --git a/src/screens/ProductListScreen/index.js b/src/screens/ProductListScreen/index.js index 67a707a..31c02d0 100644 --- a/src/screens/ProductListScreen/index.js +++ b/src/screens/ProductListScreen/index.js @@ -1,20 +1,17 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { View } from 'react-native'; import { useDispatch } from 'react-redux'; import { useTheme } from 'react-native-paper'; import { Button } from '@components/atoms'; -import { BasketSummary } from '@components/molecules'; +import { BasketSummary, ErrorState, LoadingState } from '@components/molecules'; import { ProductList } from '@components/organisms'; import { BaseScreen } from '@components/templates'; import { addItemToBasket } from '@redux/slices/basketSlice'; import { useGetProductsQuery } from '@redux/api/apiSlice'; import { validateBasket } from '@validate'; -import showToast from '@utils/showToast'; -import { toastMessages, strings } from '@constants'; +import { strings } from '@constants'; import { useBasket, useNavigationHandlers } from '@hooks'; import styles from './ProductListScreen.style'; -import LoadingState from './LoadingState'; -import ErrorState from './ErrorState'; const ProductListScreen = () => { const { colors } = useTheme(); @@ -23,24 +20,14 @@ const ProductListScreen = () => { const { navigateToCheckout } = useNavigationHandlers(); const { data: products, error, isLoading, refetch } = useGetProductsQuery(); - const basketItemsMap = useMemo(() => new Map(basketItems.map((item) => [item.id, item])), [basketItems]); const isCheckoutButtonVisible = !error && !isLoading; const isBasketSummaryVisible = !error && !isLoading; const onCheckoutPress = () => { - if (!validateBasket(basketItems)) { - showToast(toastMessages.promo.error); - return; - } navigateToCheckout(); }; const onAddToBasket = (item) => { - const existingItem = basketItemsMap.get(item.id); - if (existingItem && existingItem.quantity >= 5) { - showToast(toastMessages.basket.limitReached); - return; - } dispatch(addItemToBasket(item)); }; From 7b6310e47e7dc68feaa1b44276030c47f9722295 Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Thu, 26 Dec 2024 15:54:42 +0100 Subject: [PATCH 3/7] test: move snapshots to parent folder and remove unnecessary __snapshots__ folders - Relocated snapshot files to the same level as their corresponding test files. - Removed unnecessary `__snapshots__` folders where only a single snapshot file existed. - Added `snapshotResolver` to Jest configuration to ensure new snapshots are generated at the desired location. --- __tests__/{__snapshots__ => }/App.test.js.snap | 0 jest.config.js | 1 + scripts/snapshotResolver.js | 8 ++++++++ .../{__snapshots__ => }/ActivityIndicator.test.js.snap | 0 .../atoms/Appbar/{__snapshots__ => }/Appbar.test.js.snap | 0 .../atoms/Button/{__snapshots__ => }/Button.test.js.snap | 0 .../atoms/Card/{__snapshots__ => }/Card.test.js.snap | 0 .../FlatList/{__snapshots__ => }/FlatList.test.js.snap | 0 .../{__snapshots__ => }/HelperText.test.js.snap | 0 .../atoms/Icon/{__snapshots__ => }/Icon.test.js.snap | 0 .../{__snapshots__ => }/LinearGradient.test.js.snap | 0 .../atoms/Switch/{__snapshots__ => }/Switch.test.js.snap | 0 .../atoms/Text/{__snapshots__ => }/Text.test.js.snap | 0 .../TextInput/{__snapshots__ => }/TextInput.test.js.snap | 0 .../{__snapshots__ => }/ActivityOverlay.test.js.snap | 0 .../{__snapshots__ => }/BasketSummary.test.js.snap | 0 .../{__snapshots__ => }/CheckoutCard.test.js.snap | 0 .../{__snapshots__ => }/ControlledInput.test.js.snap | 0 .../{__snapshots__ => }/ErrorState.test.js.snap | 0 .../Header/{__snapshots__ => }/Header.test.js.snap | 0 .../Input/{__snapshots__ => }/Input.test.js.snap | 0 .../{__snapshots__ => }/LoadingState.test.js.snap | 0 .../{__snapshots__ => }/ProductCard.test.js.snap | 0 .../Toggle/{__snapshots__ => }/Toggle.test.js.snap | 0 .../{__snapshots__ => }/BaseScreen.test.js.snap | 0 .../RedirectWithAnimation.test.js.snap | 0 .../{__snapshots__ => }/CheckoutScreen.test.js.snap | 0 .../{__snapshots__ => }/ErrorScreen.test.js.snap | 0 .../{__snapshots__ => }/SuccessScreen.test.js.snap | 0 29 files changed, 9 insertions(+) rename __tests__/{__snapshots__ => }/App.test.js.snap (100%) create mode 100644 scripts/snapshotResolver.js rename src/components/atoms/ActivityIndicator/{__snapshots__ => }/ActivityIndicator.test.js.snap (100%) rename src/components/atoms/Appbar/{__snapshots__ => }/Appbar.test.js.snap (100%) rename src/components/atoms/Button/{__snapshots__ => }/Button.test.js.snap (100%) rename src/components/atoms/Card/{__snapshots__ => }/Card.test.js.snap (100%) rename src/components/atoms/FlatList/{__snapshots__ => }/FlatList.test.js.snap (100%) rename src/components/atoms/HelperText/{__snapshots__ => }/HelperText.test.js.snap (100%) rename src/components/atoms/Icon/{__snapshots__ => }/Icon.test.js.snap (100%) rename src/components/atoms/LinearGradient/{__snapshots__ => }/LinearGradient.test.js.snap (100%) rename src/components/atoms/Switch/{__snapshots__ => }/Switch.test.js.snap (100%) rename src/components/atoms/Text/{__snapshots__ => }/Text.test.js.snap (100%) rename src/components/atoms/TextInput/{__snapshots__ => }/TextInput.test.js.snap (100%) rename src/components/molecules/ActivityOverlay/{__snapshots__ => }/ActivityOverlay.test.js.snap (100%) rename src/components/molecules/BasketSummary/{__snapshots__ => }/BasketSummary.test.js.snap (100%) rename src/components/molecules/CheckoutCard/{__snapshots__ => }/CheckoutCard.test.js.snap (100%) rename src/components/molecules/ControlledInput/{__snapshots__ => }/ControlledInput.test.js.snap (100%) rename src/components/molecules/ErrorState/{__snapshots__ => }/ErrorState.test.js.snap (100%) rename src/components/molecules/Header/{__snapshots__ => }/Header.test.js.snap (100%) rename src/components/molecules/Input/{__snapshots__ => }/Input.test.js.snap (100%) rename src/components/molecules/LoadingState/{__snapshots__ => }/LoadingState.test.js.snap (100%) rename src/components/molecules/ProductCard/{__snapshots__ => }/ProductCard.test.js.snap (100%) rename src/components/molecules/Toggle/{__snapshots__ => }/Toggle.test.js.snap (100%) rename src/components/templates/BaseScreen/{__snapshots__ => }/BaseScreen.test.js.snap (100%) rename src/components/templates/RedirectWithAnimation/{__snapshots__ => }/RedirectWithAnimation.test.js.snap (100%) rename src/screens/CheckoutScreen/{__snapshots__ => }/CheckoutScreen.test.js.snap (100%) rename src/screens/ErrorScreen/{__snapshots__ => }/ErrorScreen.test.js.snap (100%) rename src/screens/SuccessScreen/{__snapshots__ => }/SuccessScreen.test.js.snap (100%) diff --git a/__tests__/__snapshots__/App.test.js.snap b/__tests__/App.test.js.snap similarity index 100% rename from __tests__/__snapshots__/App.test.js.snap rename to __tests__/App.test.js.snap diff --git a/jest.config.js b/jest.config.js index 00b1f30..9316690 100644 --- a/jest.config.js +++ b/jest.config.js @@ -40,4 +40,5 @@ module.exports = { '^@mocks/(.*)$': '/__tests__/mocks/$1', }, coverageReporters: ['json', 'json-summary', 'text', 'lcov'], + snapshotResolver: './scripts/snapshotResolver.js', }; diff --git a/scripts/snapshotResolver.js b/scripts/snapshotResolver.js new file mode 100644 index 0000000..978ee51 --- /dev/null +++ b/scripts/snapshotResolver.js @@ -0,0 +1,8 @@ +// eslint-disable-next-line no-unused-vars +const path = require('path'); + +module.exports = { + resolveSnapshotPath: (testPath, snapshotExtension) => testPath + snapshotExtension, // Place snapshots next to the test file + resolveTestPath: (snapshotPath, snapshotExtension) => snapshotPath.slice(0, -snapshotExtension.length), // Match snapshots with test files + testPathForConsistencyCheck: 'some/example.test.js', +}; diff --git a/src/components/atoms/ActivityIndicator/__snapshots__/ActivityIndicator.test.js.snap b/src/components/atoms/ActivityIndicator/ActivityIndicator.test.js.snap similarity index 100% rename from src/components/atoms/ActivityIndicator/__snapshots__/ActivityIndicator.test.js.snap rename to src/components/atoms/ActivityIndicator/ActivityIndicator.test.js.snap diff --git a/src/components/atoms/Appbar/__snapshots__/Appbar.test.js.snap b/src/components/atoms/Appbar/Appbar.test.js.snap similarity index 100% rename from src/components/atoms/Appbar/__snapshots__/Appbar.test.js.snap rename to src/components/atoms/Appbar/Appbar.test.js.snap diff --git a/src/components/atoms/Button/__snapshots__/Button.test.js.snap b/src/components/atoms/Button/Button.test.js.snap similarity index 100% rename from src/components/atoms/Button/__snapshots__/Button.test.js.snap rename to src/components/atoms/Button/Button.test.js.snap diff --git a/src/components/atoms/Card/__snapshots__/Card.test.js.snap b/src/components/atoms/Card/Card.test.js.snap similarity index 100% rename from src/components/atoms/Card/__snapshots__/Card.test.js.snap rename to src/components/atoms/Card/Card.test.js.snap diff --git a/src/components/atoms/FlatList/__snapshots__/FlatList.test.js.snap b/src/components/atoms/FlatList/FlatList.test.js.snap similarity index 100% rename from src/components/atoms/FlatList/__snapshots__/FlatList.test.js.snap rename to src/components/atoms/FlatList/FlatList.test.js.snap diff --git a/src/components/atoms/HelperText/__snapshots__/HelperText.test.js.snap b/src/components/atoms/HelperText/HelperText.test.js.snap similarity index 100% rename from src/components/atoms/HelperText/__snapshots__/HelperText.test.js.snap rename to src/components/atoms/HelperText/HelperText.test.js.snap diff --git a/src/components/atoms/Icon/__snapshots__/Icon.test.js.snap b/src/components/atoms/Icon/Icon.test.js.snap similarity index 100% rename from src/components/atoms/Icon/__snapshots__/Icon.test.js.snap rename to src/components/atoms/Icon/Icon.test.js.snap diff --git a/src/components/atoms/LinearGradient/__snapshots__/LinearGradient.test.js.snap b/src/components/atoms/LinearGradient/LinearGradient.test.js.snap similarity index 100% rename from src/components/atoms/LinearGradient/__snapshots__/LinearGradient.test.js.snap rename to src/components/atoms/LinearGradient/LinearGradient.test.js.snap diff --git a/src/components/atoms/Switch/__snapshots__/Switch.test.js.snap b/src/components/atoms/Switch/Switch.test.js.snap similarity index 100% rename from src/components/atoms/Switch/__snapshots__/Switch.test.js.snap rename to src/components/atoms/Switch/Switch.test.js.snap diff --git a/src/components/atoms/Text/__snapshots__/Text.test.js.snap b/src/components/atoms/Text/Text.test.js.snap similarity index 100% rename from src/components/atoms/Text/__snapshots__/Text.test.js.snap rename to src/components/atoms/Text/Text.test.js.snap diff --git a/src/components/atoms/TextInput/__snapshots__/TextInput.test.js.snap b/src/components/atoms/TextInput/TextInput.test.js.snap similarity index 100% rename from src/components/atoms/TextInput/__snapshots__/TextInput.test.js.snap rename to src/components/atoms/TextInput/TextInput.test.js.snap diff --git a/src/components/molecules/ActivityOverlay/__snapshots__/ActivityOverlay.test.js.snap b/src/components/molecules/ActivityOverlay/ActivityOverlay.test.js.snap similarity index 100% rename from src/components/molecules/ActivityOverlay/__snapshots__/ActivityOverlay.test.js.snap rename to src/components/molecules/ActivityOverlay/ActivityOverlay.test.js.snap diff --git a/src/components/molecules/BasketSummary/__snapshots__/BasketSummary.test.js.snap b/src/components/molecules/BasketSummary/BasketSummary.test.js.snap similarity index 100% rename from src/components/molecules/BasketSummary/__snapshots__/BasketSummary.test.js.snap rename to src/components/molecules/BasketSummary/BasketSummary.test.js.snap diff --git a/src/components/molecules/CheckoutCard/__snapshots__/CheckoutCard.test.js.snap b/src/components/molecules/CheckoutCard/CheckoutCard.test.js.snap similarity index 100% rename from src/components/molecules/CheckoutCard/__snapshots__/CheckoutCard.test.js.snap rename to src/components/molecules/CheckoutCard/CheckoutCard.test.js.snap diff --git a/src/components/molecules/ControlledInput/__snapshots__/ControlledInput.test.js.snap b/src/components/molecules/ControlledInput/ControlledInput.test.js.snap similarity index 100% rename from src/components/molecules/ControlledInput/__snapshots__/ControlledInput.test.js.snap rename to src/components/molecules/ControlledInput/ControlledInput.test.js.snap diff --git a/src/components/molecules/ErrorState/__snapshots__/ErrorState.test.js.snap b/src/components/molecules/ErrorState/ErrorState.test.js.snap similarity index 100% rename from src/components/molecules/ErrorState/__snapshots__/ErrorState.test.js.snap rename to src/components/molecules/ErrorState/ErrorState.test.js.snap diff --git a/src/components/molecules/Header/__snapshots__/Header.test.js.snap b/src/components/molecules/Header/Header.test.js.snap similarity index 100% rename from src/components/molecules/Header/__snapshots__/Header.test.js.snap rename to src/components/molecules/Header/Header.test.js.snap diff --git a/src/components/molecules/Input/__snapshots__/Input.test.js.snap b/src/components/molecules/Input/Input.test.js.snap similarity index 100% rename from src/components/molecules/Input/__snapshots__/Input.test.js.snap rename to src/components/molecules/Input/Input.test.js.snap diff --git a/src/components/molecules/LoadingState/__snapshots__/LoadingState.test.js.snap b/src/components/molecules/LoadingState/LoadingState.test.js.snap similarity index 100% rename from src/components/molecules/LoadingState/__snapshots__/LoadingState.test.js.snap rename to src/components/molecules/LoadingState/LoadingState.test.js.snap diff --git a/src/components/molecules/ProductCard/__snapshots__/ProductCard.test.js.snap b/src/components/molecules/ProductCard/ProductCard.test.js.snap similarity index 100% rename from src/components/molecules/ProductCard/__snapshots__/ProductCard.test.js.snap rename to src/components/molecules/ProductCard/ProductCard.test.js.snap diff --git a/src/components/molecules/Toggle/__snapshots__/Toggle.test.js.snap b/src/components/molecules/Toggle/Toggle.test.js.snap similarity index 100% rename from src/components/molecules/Toggle/__snapshots__/Toggle.test.js.snap rename to src/components/molecules/Toggle/Toggle.test.js.snap diff --git a/src/components/templates/BaseScreen/__snapshots__/BaseScreen.test.js.snap b/src/components/templates/BaseScreen/BaseScreen.test.js.snap similarity index 100% rename from src/components/templates/BaseScreen/__snapshots__/BaseScreen.test.js.snap rename to src/components/templates/BaseScreen/BaseScreen.test.js.snap diff --git a/src/components/templates/RedirectWithAnimation/__snapshots__/RedirectWithAnimation.test.js.snap b/src/components/templates/RedirectWithAnimation/RedirectWithAnimation.test.js.snap similarity index 100% rename from src/components/templates/RedirectWithAnimation/__snapshots__/RedirectWithAnimation.test.js.snap rename to src/components/templates/RedirectWithAnimation/RedirectWithAnimation.test.js.snap diff --git a/src/screens/CheckoutScreen/__snapshots__/CheckoutScreen.test.js.snap b/src/screens/CheckoutScreen/CheckoutScreen.test.js.snap similarity index 100% rename from src/screens/CheckoutScreen/__snapshots__/CheckoutScreen.test.js.snap rename to src/screens/CheckoutScreen/CheckoutScreen.test.js.snap diff --git a/src/screens/ErrorScreen/__snapshots__/ErrorScreen.test.js.snap b/src/screens/ErrorScreen/ErrorScreen.test.js.snap similarity index 100% rename from src/screens/ErrorScreen/__snapshots__/ErrorScreen.test.js.snap rename to src/screens/ErrorScreen/ErrorScreen.test.js.snap diff --git a/src/screens/SuccessScreen/__snapshots__/SuccessScreen.test.js.snap b/src/screens/SuccessScreen/SuccessScreen.test.js.snap similarity index 100% rename from src/screens/SuccessScreen/__snapshots__/SuccessScreen.test.js.snap rename to src/screens/SuccessScreen/SuccessScreen.test.js.snap From f59ba7d1a65fb633ad0dfb53ea9c4bc63ef252ea Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Thu, 26 Dec 2024 16:33:55 +0100 Subject: [PATCH 4/7] feat: enhance CheckoutScreen with modular components and hooks - Split `CheckoutScreen` into `TotalSummary` and `PromoCodeInput` for better modularity. - Introduced `useCheckout` custom hook to encapsulate business logic, improving testability and readability. --- .eslintrc.json | 1 + scripts/snapshotResolver.js | 3 - .../molecules/ControlledInput/index.js | 6 +- src/hooks/index.js | 1 + src/hooks/useCheckout/useCheckout.js | 46 + .../CheckoutScreen/CheckoutScreen.style.js | 4 - .../CheckoutScreen.test.js.snap | 1180 ++++++++--------- .../components/PromoCodeInput/index.js | 55 + .../TotalSummary/TotalSummary.style.js | 12 + .../components/TotalSummary/index.js | 25 + .../CheckoutScreen/components/index.js | 2 + src/screens/CheckoutScreen/index.js | 120 +- .../PaymentScreen/PaymentForm/index.js | 4 +- 13 files changed, 754 insertions(+), 705 deletions(-) create mode 100644 src/hooks/useCheckout/useCheckout.js create mode 100644 src/screens/CheckoutScreen/components/PromoCodeInput/index.js create mode 100644 src/screens/CheckoutScreen/components/TotalSummary/TotalSummary.style.js create mode 100644 src/screens/CheckoutScreen/components/TotalSummary/index.js create mode 100644 src/screens/CheckoutScreen/components/index.js diff --git a/.eslintrc.json b/.eslintrc.json index 94ebea6..39f90b3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -73,6 +73,7 @@ ], "prettier/prettier": "error", "react/react-in-jsx-scope": "off", + "react/forbid-prop-types": "off", // prevent eslint to complain about the "styles" variable being used before it was defined "no-use-before-define": [ "error", diff --git a/scripts/snapshotResolver.js b/scripts/snapshotResolver.js index 978ee51..8b34f62 100644 --- a/scripts/snapshotResolver.js +++ b/scripts/snapshotResolver.js @@ -1,6 +1,3 @@ -// eslint-disable-next-line no-unused-vars -const path = require('path'); - module.exports = { resolveSnapshotPath: (testPath, snapshotExtension) => testPath + snapshotExtension, // Place snapshots next to the test file resolveTestPath: (snapshotPath, snapshotExtension) => snapshotPath.slice(0, -snapshotExtension.length), // Match snapshots with test files diff --git a/src/components/molecules/ControlledInput/index.js b/src/components/molecules/ControlledInput/index.js index 883416d..6021a45 100644 --- a/src/components/molecules/ControlledInput/index.js +++ b/src/components/molecules/ControlledInput/index.js @@ -36,16 +36,16 @@ const ControlledInput = ({ ); ControlledInput.propTypes = { - control: PropTypes.oneOfType([PropTypes.object]).isRequired, + control: PropTypes.object.isRequired, name: PropTypes.string.isRequired, label: PropTypes.string.isRequired, placeholder: PropTypes.string, maxLength: PropTypes.number, keyboardType: PropTypes.string, - errorObject: PropTypes.oneOfType([PropTypes.object]), + errorObject: PropTypes.object, right: PropTypes.node, formatValue: PropTypes.func, - rules: PropTypes.oneOfType([PropTypes.object]), + rules: PropTypes.object, }; export default ControlledInput; diff --git a/src/hooks/index.js b/src/hooks/index.js index 67ac358..3ded3b6 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -1,3 +1,4 @@ export { default as useBasket } from './useBasket/useBasket'; export { default as useBackHandler } from './useBackHandler/useBackHandler'; export { default as useNavigationHandlers } from './useNavigationHandlers/useNavigationHandlers'; +export { default as useCheckout } from './useCheckout/useCheckout'; diff --git a/src/hooks/useCheckout/useCheckout.js b/src/hooks/useCheckout/useCheckout.js new file mode 100644 index 0000000..8e45544 --- /dev/null +++ b/src/hooks/useCheckout/useCheckout.js @@ -0,0 +1,46 @@ +import { useDispatch } from 'react-redux'; +import { removeItemFromBasket, updateItemQuantity, setDiscount } from '@redux/slices/basketSlice'; +import { useValidatePromoCodeMutation } from '@redux/api/apiSlice'; +import useBasket from '@hooks/useBasket/useBasket'; +import { showToast } from '@utils'; +import { toastMessages } from '@constants'; + +const useCheckout = () => { + const dispatch = useDispatch(); + const { basketItems, totalItemCount, totalPrice } = useBasket(); + const [validatePromoCode, { isLoading }] = useValidatePromoCodeMutation(); + + const isBasketEmpty = basketItems.length === 0; + + const onRemoveItem = (item) => dispatch(removeItemFromBasket(item.id)); + + const onApplyPromoCode = async (promoCode) => { + try { + const { amount } = await validatePromoCode(promoCode).unwrap(); + if (amount) { + dispatch(setDiscount(amount)); + showToast(toastMessages.promo.success); + } else { + showToast(toastMessages.promo.invalid); + } + } catch (err) { + const errorMsg = err?.msg || toastMessages.promo.error; + showToast({ ...toastMessages.promo.error, msg: errorMsg }); + } + }; + + const onQuantityChange = (item, newQuantity) => dispatch(updateItemQuantity({ id: item.id, quantity: newQuantity })); + + return { + basketItems, + totalItemCount, + totalPrice, + isBasketEmpty, + isLoading, + onRemoveItem, + onApplyPromoCode, + onQuantityChange, + }; +}; + +export default useCheckout; diff --git a/src/screens/CheckoutScreen/CheckoutScreen.style.js b/src/screens/CheckoutScreen/CheckoutScreen.style.js index cf32aa2..7b6b90c 100644 --- a/src/screens/CheckoutScreen/CheckoutScreen.style.js +++ b/src/screens/CheckoutScreen/CheckoutScreen.style.js @@ -15,8 +15,4 @@ export default StyleSheet.create({ orderButtonContainer: { flex: 4, }, - promoContainer: { - flex: 6, - marginBottom: spacing.sm, - }, }); diff --git a/src/screens/CheckoutScreen/CheckoutScreen.test.js.snap b/src/screens/CheckoutScreen/CheckoutScreen.test.js.snap index c0182da..927c985 100644 --- a/src/screens/CheckoutScreen/CheckoutScreen.test.js.snap +++ b/src/screens/CheckoutScreen/CheckoutScreen.test.js.snap @@ -16,7 +16,7 @@ exports[`CheckoutScreen should match the snapshot 1`] = ` - Total: - $ + Total: $ 0.00 - - - - - 󰵦 - - - + 󰵦 + + + - ORDER - ( - 0 - items) - - + ], + ] + } + testID="button-text" + > + Order ( + 0 + items) + + + + - - - - Promo Code - - + - Promo Code - - + } + testID="text-input-flat-label-inactive" + > + Promo Code + - - + + + - + - - 󰏰 - - + 󰏰 + - + + + - - - 󰴯 - - - + 󰴯 + + + - APPLY PROMO CODE - - + ], + ] + } + testID="button-text" + > + Apply + diff --git a/src/screens/CheckoutScreen/components/PromoCodeInput/index.js b/src/screens/CheckoutScreen/components/PromoCodeInput/index.js new file mode 100644 index 0000000..1c0a299 --- /dev/null +++ b/src/screens/CheckoutScreen/components/PromoCodeInput/index.js @@ -0,0 +1,55 @@ +import React from 'react'; +import { Controller } from 'react-hook-form'; +import { Button, TextInput } from '@components/atoms'; +import { Input } from '@components/molecules'; +import { useTheme } from 'react-native-paper'; +import PropTypes from 'prop-types'; +import { View } from 'react-native'; + +const PromoCodeInput = ({ control, errors, handleSubmit, onApplyPromoCode, register, isApplyButtonDisabled }) => { + const { colors } = useTheme(); + + return ( + + ( + } + errorObject={errors.promoCode} + /> + )} + name="promoCode" + /> + + + ); +}; + +PromoCodeInput.propTypes = { + control: PropTypes.object.isRequired, + errors: PropTypes.object, + handleSubmit: PropTypes.func.isRequired, + onApplyPromoCode: PropTypes.func.isRequired, + register: PropTypes.func.isRequired, + isApplyButtonDisabled: PropTypes.bool.isRequired, +}; + +export default PromoCodeInput; diff --git a/src/screens/CheckoutScreen/components/TotalSummary/TotalSummary.style.js b/src/screens/CheckoutScreen/components/TotalSummary/TotalSummary.style.js new file mode 100644 index 0000000..3544930 --- /dev/null +++ b/src/screens/CheckoutScreen/components/TotalSummary/TotalSummary.style.js @@ -0,0 +1,12 @@ +import { StyleSheet } from 'react-native'; +import { spacing } from '@constants/theme'; + +export default StyleSheet.create({ + totalContainer: { + marginBottom: spacing.md, + paddingLeft: spacing.xxs, + }, + totalPrice: { + marginBottom: spacing.special, + }, +}); diff --git a/src/screens/CheckoutScreen/components/TotalSummary/index.js b/src/screens/CheckoutScreen/components/TotalSummary/index.js new file mode 100644 index 0000000..835f50c --- /dev/null +++ b/src/screens/CheckoutScreen/components/TotalSummary/index.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Button, Text } from '@components/atoms'; +import PropTypes from 'prop-types'; +import styles from './TotalSummary.style'; + +const TotalSummary = ({ totalPrice, totalItemCount, isOrderButtonDisabled, onOrderPress }) => ( + + + Total: ${Number.isNaN(totalPrice) ? '0.00' : totalPrice.toFixed(2)} + + + +); + +TotalSummary.propTypes = { + totalPrice: PropTypes.number.isRequired, + totalItemCount: PropTypes.number.isRequired, + isOrderButtonDisabled: PropTypes.bool.isRequired, + onOrderPress: PropTypes.func.isRequired, +}; + +export default TotalSummary; diff --git a/src/screens/CheckoutScreen/components/index.js b/src/screens/CheckoutScreen/components/index.js new file mode 100644 index 0000000..2d02e13 --- /dev/null +++ b/src/screens/CheckoutScreen/components/index.js @@ -0,0 +1,2 @@ +export { default as TotalSummary } from './TotalSummary'; +export { default as PromoCodeInput } from './PromoCodeInput'; diff --git a/src/screens/CheckoutScreen/index.js b/src/screens/CheckoutScreen/index.js index db35b7b..e91d624 100644 --- a/src/screens/CheckoutScreen/index.js +++ b/src/screens/CheckoutScreen/index.js @@ -1,25 +1,24 @@ import React from 'react'; -import { View } from 'react-native'; -import { useTheme } from 'react-native-paper'; -import { useDispatch } from 'react-redux'; -import { useForm, Controller } from 'react-hook-form'; -import { Button, Text, TextInput } from '@components/atoms'; -import { Input, ActivityOverlay } from '@components/molecules'; +import { useForm } from 'react-hook-form'; +import { ActivityOverlay } from '@components/molecules'; import { CheckoutList } from '@components/organisms'; import { BaseScreen } from '@components/templates'; -import { removeItemFromBasket, updateItemQuantity, setDiscount } from '@redux/slices/basketSlice'; -import { useValidatePromoCodeMutation } from '@redux/api/apiSlice'; -import { validateBasket } from '@validate'; -import { showToast } from '@utils'; -import { toastMessages, strings } from '@constants'; -import styles from './CheckoutScreen.style'; -import { useBasket, useNavigationHandlers } from '../../hooks'; +import { useCheckout, useNavigationHandlers } from '@hooks'; +import { PromoCodeInput, TotalSummary } from './components'; const CheckoutScreen = () => { - const { colors } = useTheme(); - const dispatch = useDispatch(); const { navigateToPayment } = useNavigationHandlers(); + const { + basketItems, + totalItemCount, + totalPrice, + isBasketEmpty, + isLoading, + onRemoveItem, + onApplyPromoCode, + onQuantityChange, + } = useCheckout(); const { control, handleSubmit, @@ -31,87 +30,28 @@ const CheckoutScreen = () => { }, }); - const { basketItems, totalItemCount, totalPrice } = useBasket(); - - const isBasketEmpty = basketItems.length === 0; - - const [validatePromoCode, { isLoading }] = useValidatePromoCodeMutation(); - - const onRemoveItem = (item) => { - dispatch(removeItemFromBasket(item.id)); - }; - const onOrderPress = () => { - if (!validateBasket(basketItems)) { - showToast(toastMessages.basket.empty); - return; - } navigateToPayment(); }; - const onApplyPromoCode = async (data) => { - try { - const { amount } = await validatePromoCode(data.promoCode).unwrap(); - if (amount) { - dispatch(setDiscount(amount)); - showToast(toastMessages.promo.success); - } else { - showToast(toastMessages.promo.invalid); - } - } catch (err) { - const errorMsg = err?.msg || toastMessages.promo.error; - showToast({ ...toastMessages.promo.error, msg: errorMsg }); - } - }; - - const onQuantityChange = (item, newQuantity) => { - dispatch(updateItemQuantity({ id: item.id, quantity: newQuantity })); - }; - return ( - - - - {strings.checkout.total} ${Number.isNaN(totalPrice) ? '0.00' : totalPrice.toFixed(2)} - - - - - - - - - - ( - } - errorObject={errors.promoCode} - /> - )} - name="promoCode" - /> - - - - - + + + onApplyPromoCode(data.promoCode)} + register={register} + /> + ); }; diff --git a/src/screens/PaymentScreen/PaymentForm/index.js b/src/screens/PaymentScreen/PaymentForm/index.js index a1e68ed..74f7c9e 100644 --- a/src/screens/PaymentScreen/PaymentForm/index.js +++ b/src/screens/PaymentScreen/PaymentForm/index.js @@ -86,8 +86,8 @@ const PaymentForm = ({ control, errors, isCreditCardValid }) => { }; PaymentForm.propTypes = { - control: PropTypes.oneOfType([PropTypes.object]).isRequired, - errors: PropTypes.oneOfType([PropTypes.object]).isRequired, + control: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, isCreditCardValid: PropTypes.bool.isRequired, }; From c841a89bc61c510cd7ae8720709a4a1bedee9e3a Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Thu, 26 Dec 2024 20:29:25 +0100 Subject: [PATCH 5/7] test(checkout): enhance CheckoutScreen tests and add integration tests - Add unit tests for cart item quantity controls: - Increment/decrement item quantity - Verify total item count and price updates - Remove item when quantity reaches 1 to 0 - Add integration tests with promocode functionality - Add mock handler for promocode API - Implement renderInFormProvider test utility - Update test snapshots --- __tests__/App.test.js.snap | 8 + __tests__/mocks/handlers.js | 9 + __tests__/utils/renderInFormProvider.js | 21 + jest.config.js | 3 +- src/components/atoms/Button/Button.test.js | 1 + src/components/atoms/Text/Text.test.js.snap | 1 + src/components/atoms/Text/index.js | 4 +- .../CheckoutCard/CheckoutCard.test.js.snap | 8 +- .../molecules/CheckoutCard/index.js | 12 +- .../ControlledInput/ControlledInput.test.js | 44 +- .../ProductCard/ProductCard.test.js.snap | 1 + .../BaseScreen/BaseScreen.test.js.snap | 1 + .../RedirectWithAnimation.test.js.snap | 2 + src/constants/strings.js | 2 +- .../CheckoutScreen.integration.test.js | 35 + .../CheckoutScreen/CheckoutScreen.test.js | 144 ++- .../CheckoutScreen.test.js.snap | 895 ------------------ .../components/PromoCodeInput/index.js | 5 +- .../TotalSummary/TotalSummary.test.js | 36 + .../components/TotalSummary/index.js | 8 +- .../ErrorScreen/ErrorScreen.test.js.snap | 2 + .../PaymentForm/PaymentForm.test.js | 15 +- .../SuccessScreen/SuccessScreen.test.js.snap | 2 + 23 files changed, 294 insertions(+), 965 deletions(-) create mode 100644 __tests__/utils/renderInFormProvider.js create mode 100644 src/screens/CheckoutScreen/CheckoutScreen.integration.test.js delete mode 100644 src/screens/CheckoutScreen/CheckoutScreen.test.js.snap create mode 100644 src/screens/CheckoutScreen/components/TotalSummary/TotalSummary.test.js diff --git a/__tests__/App.test.js.snap b/__tests__/App.test.js.snap index b4bdd6e..fa619ff 100644 --- a/__tests__/App.test.js.snap +++ b/__tests__/App.test.js.snap @@ -596,6 +596,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 109.95 @@ -1041,6 +1042,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 22.30 @@ -1505,6 +1507,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 55.99 @@ -1950,6 +1953,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 15.99 @@ -2414,6 +2418,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 695.00 @@ -2859,6 +2864,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 168.00 @@ -3323,6 +3329,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 9.99 @@ -3768,6 +3775,7 @@ exports[` Text renders correctly on App 1`] = ` ], ] } + testID="text" > € 10.99 diff --git a/__tests__/mocks/handlers.js b/__tests__/mocks/handlers.js index ce53401..e39352d 100644 --- a/__tests__/mocks/handlers.js +++ b/__tests__/mocks/handlers.js @@ -188,4 +188,13 @@ export const handlers = [ http.post('/checkout', (req, res, ctx) => { return res(ctx.status(200), ctx.json({ success: true })); }), + + http.post('http://localhost:9001/promocode', async (req, res, ctx) => { + try { + return HttpResponse.json({ discountType: 'percent', amount: 10 }); + } catch (error) { + console.error('Error:', error); + return res(ctx.status(400), ctx.json({ message: 'Invalid promo code' })); + } + }), ]; diff --git a/__tests__/utils/renderInFormProvider.js b/__tests__/utils/renderInFormProvider.js new file mode 100644 index 0000000..d83d47f --- /dev/null +++ b/__tests__/utils/renderInFormProvider.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { useForm, FormProvider } from 'react-hook-form'; + +/** + * Utility to render a component wrapped in a FormProvider + * @param {React.ReactNode} ui - The React component to render. + * @param {object} formOptions - Options to initialize useForm (default: {}). + * @param {object} renderOptions - Additional options for the render method (default: {}). + * @returns {object} - The result of the render function. + */ +const renderInFormProvider = (ui, formOptions = {}, renderOptions = {}) => { + const Wrapper = ({ children }) => { + const methods = useForm(formOptions); + return {children}; + }; + + return render(ui, { wrapper: Wrapper, ...renderOptions }); +}; + +export default renderInFormProvider; diff --git a/jest.config.js b/jest.config.js index 9316690..048b7cd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,7 +23,8 @@ module.exports = { 'src/components/organisms/index.js', 'src/components/templates/index.js', 'src/screens/index.js', - 'src/screens/PaymentScreen/components/index.js', + 'src/hooks/index.js', + 'src/screens/CheckoutScreen/components/index.js', 'wdyr.js', ], testPathIgnorePatterns: ['/__tests__/mocks/', '/__tests__/utils/'], diff --git a/src/components/atoms/Button/Button.test.js b/src/components/atoms/Button/Button.test.js index ecb90db..49ee5ca 100644 --- a/src/components/atoms/Button/Button.test.js +++ b/src/components/atoms/Button/Button.test.js @@ -58,6 +58,7 @@ describe(' - {product.quantity} + + {product.quantity} + diff --git a/src/screens/ErrorScreen/ErrorScreen.test.js.snap b/src/screens/ErrorScreen/ErrorScreen.test.js.snap index 763a611..71b8700 100644 --- a/src/screens/ErrorScreen/ErrorScreen.test.js.snap +++ b/src/screens/ErrorScreen/ErrorScreen.test.js.snap @@ -64,6 +64,7 @@ exports[`ErrorScreen Component should match the snapshot 1`] = ` ], ] } + testID="text" > Snapshot Error Test @@ -97,6 +98,7 @@ exports[`ErrorScreen Component should match the snapshot 1`] = ` ], ] } + testID="text" > Redirecting to product list in 10 diff --git a/src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js b/src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js index d981cac..e5d8abd 100644 --- a/src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js +++ b/src/screens/PaymentScreen/PaymentForm/PaymentForm.test.js @@ -1,8 +1,7 @@ import React from 'react'; import { screen } from '@testing-library/react-native'; -import { useForm, FormProvider } from 'react-hook-form'; import { strings } from '@constants'; -import { renderInThemeProvider } from '@testUtils/renderInThemeProvider'; +import renderInFormProvider from '@testUtils/renderInFormProvider'; import PaymentForm from './index'; jest.mock('@utils', () => ({ @@ -14,12 +13,6 @@ jest.mock('@utils', () => ({ })); describe('', () => { - // eslint-disable-next-line react/prop-types - const Wrapper = ({ children }) => { - const methods = useForm(); - return {children}; - }; - const mockErrors = { cardholderName: false, creditCardNumber: false, @@ -28,11 +21,7 @@ describe('', () => { }; it('renders all inputs with correct labels', () => { - renderInThemeProvider( - - - , - ); + renderInFormProvider(); expect(screen.getAllByText(strings.payment.cardholderName).length).toBe(2); expect(screen.getAllByText(strings.payment.creditCardNumber).length).toBe(2); diff --git a/src/screens/SuccessScreen/SuccessScreen.test.js.snap b/src/screens/SuccessScreen/SuccessScreen.test.js.snap index 4e5a7c1..c101f54 100644 --- a/src/screens/SuccessScreen/SuccessScreen.test.js.snap +++ b/src/screens/SuccessScreen/SuccessScreen.test.js.snap @@ -64,6 +64,7 @@ exports[`SuccessScreen Component should match the snapshot 1`] = ` ], ] } + testID="text" > Thank you! @@ -100,6 +101,7 @@ Your order has been placed successfully. ], ] } + testID="text" > Redirecting to product list in 5 From 5a08ed24346a2d63b719de1fd54cc76e6ad37f58 Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Fri, 27 Dec 2024 00:40:47 +0100 Subject: [PATCH 6/7] test: add primitive integration test for PaymentScreen --- __tests__/App.test.js | 3 +- __tests__/App.test.js.snap | 4291 ----------------- __tests__/mocks/handlers.js | 17 +- .../CheckoutScreen.integration.test.js | 1 - .../PaymentScreen.integration.test.js | 49 + .../PaymentScreen/PaymentScreen.test.js | 2 +- 6 files changed, 66 insertions(+), 4297 deletions(-) delete mode 100644 __tests__/App.test.js.snap create mode 100644 src/screens/PaymentScreen/PaymentScreen.integration.test.js diff --git a/__tests__/App.test.js b/__tests__/App.test.js index 9bdf67c..a507c24 100644 --- a/__tests__/App.test.js +++ b/__tests__/App.test.js @@ -3,11 +3,10 @@ import App from '../App'; import renderInProvider from './utils/renderInProvider'; describe('', () => { - test('Text renders correctly on App', async () => { + test('App renders correctly and displays the initial text', async () => { const { toJSON } = renderInProvider(); await waitFor(() => { screen.getByText('Items in the basket: 0'); }); - expect(toJSON()).toMatchSnapshot(); }); }); diff --git a/__tests__/App.test.js.snap b/__tests__/App.test.js.snap deleted file mode 100644 index fa619ff..0000000 --- a/__tests__/App.test.js.snap +++ /dev/null @@ -1,4291 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` Text renders correctly on App 1`] = ` - - - - - - - - - - - - - - Items in the basket: - - 0 - - - - Total: - $ - 0.00 - - - - - } - removeClippedSubviews={true} - renderItem={[Function]} - scrollEventThrottle={0.0001} - stickyHeaderIndices={[]} - style={ - { - "marginTop": 7, - } - } - viewabilityConfigCallbackPairs={[]} - windowSize={10} - > - - - - - - - - - - - - - - Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops - - - ★★★★☆ - - - - - - - € - 109.95 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - Mens Casual Premium Slim Fit T-Shirts - - - ★★★★☆ - - - - - - - € - 22.30 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - - - - - Mens Cotton Jacket - - - ★★★★★ - - - - - - - € - 55.99 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - Mens Casual Slim Fit - - - ★★☆☆☆ - - - - - - - € - 15.99 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - - - - - John Hardy Women's Legends Naga Gold & Silver Dragon Station Chain Bracelet - - - ★★★★★ - - - - - - - € - 695.00 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - Solid Gold Petite Micropave - - - ★★★★☆ - - - - - - - € - 168.00 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - - - - - White Gold Plated Princess - - - ★★★☆☆ - - - - - - - € - 9.99 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - Pierced Owl Rose Gold Plated Stainless Steel Double - - - ★★☆☆☆ - - - - - - - € - 10.99 - - - - - - - - - Add to basket - - - - - - - - - - - - - - - - - - - - - 󰵦 - - - - CHECKOUT - - - - - - - - - - - - - - - -`; diff --git a/__tests__/mocks/handlers.js b/__tests__/mocks/handlers.js index e39352d..cdbc001 100644 --- a/__tests__/mocks/handlers.js +++ b/__tests__/mocks/handlers.js @@ -185,8 +185,12 @@ export const handlers = [ }), // Mock the /checkout API endpoint - http.post('/checkout', (req, res, ctx) => { - return res(ctx.status(200), ctx.json({ success: true })); + http.post('http://localhost:9001/checkout', async (req, res, ctx) => { + try { + return HttpResponse.json({ msg: 'The transaction was completed successfully.' }); + } catch (error) { + console.error('Error processing /checkout:', error); + } }), http.post('http://localhost:9001/promocode', async (req, res, ctx) => { @@ -197,4 +201,13 @@ export const handlers = [ return res(ctx.status(400), ctx.json({ message: 'Invalid promo code' })); } }), + + http.post('http://localhost:9001/checkout', async (req, res, ctx) => { + try { + return HttpResponse.json({ msg: 'The transaction was completed successfully.' }); + } catch (error) { + console.error('Error:', error); + return res(ctx.status(400), ctx.json({ message: 'Invalid promo code' })); + } + }), ]; diff --git a/src/screens/CheckoutScreen/CheckoutScreen.integration.test.js b/src/screens/CheckoutScreen/CheckoutScreen.integration.test.js index 60c2176..38dd083 100644 --- a/src/screens/CheckoutScreen/CheckoutScreen.integration.test.js +++ b/src/screens/CheckoutScreen/CheckoutScreen.integration.test.js @@ -9,7 +9,6 @@ const initialState = { basket: { items: sampleBasket } }; // integration test with msw describe('CheckoutScreen', () => { - // Integration test with msw, //TODO split into separate file it('should apply a promo code and update the total price', async () => { // console.log('Rendering CheckoutScreen...'); renderWithProvidersAndNavigation(, { initialState }); diff --git a/src/screens/PaymentScreen/PaymentScreen.integration.test.js b/src/screens/PaymentScreen/PaymentScreen.integration.test.js new file mode 100644 index 0000000..f3853dd --- /dev/null +++ b/src/screens/PaymentScreen/PaymentScreen.integration.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import renderWithProvidersAndNavigation from '@testUtils/renderInProvidersAndNavigation'; +import { sampleBasket } from '@mocks/handlers'; +import { useNavigation } from '@react-navigation/native'; +import PaymentScreen from '.'; +import { strings } from '../../constants'; + +const initialState = { basket: { items: sampleBasket } }; + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: jest.fn(), +})); + +const navigateMock = jest.fn(); + +useNavigation.mockReturnValue({ + navigate: navigateMock, +}); + +// integration test with msw +describe('PaymentScreen', () => { + it('should navigate SuccessScreen when filled valid inputs and PAY and ORDER button press', async () => { + // console.log('Rendering CheckoutScreen...'); + renderWithProvidersAndNavigation(, { initialState }); + + const cardHolderNameInput = screen.getAllByText(strings.payment.cardholderName)[0]; + fireEvent.changeText(cardHolderNameInput, 'James Bond'); + + const creditCardInput = screen.getAllByText(strings.payment.creditCardNumber)[0]; + fireEvent.changeText(creditCardInput, '5566561551349323'); + + const expirationInput = screen.getAllByText(strings.payment.expirationDate)[0]; + fireEvent.changeText(expirationInput, '12/28'); + + const cvvInput = screen.getAllByText(strings.payment.cvv)[0]; + fireEvent.changeText(cvvInput, '156'); + + const payAndOrder = screen.getByText(strings.buttons.payAndOrder); + fireEvent.press(payAndOrder); + + await waitFor(() => { + expect(navigateMock).toHaveBeenCalledWith('Success'); + }); + }, 10000); + + // TODO Error case +}); diff --git a/src/screens/PaymentScreen/PaymentScreen.test.js b/src/screens/PaymentScreen/PaymentScreen.test.js index a7b5fde..1ed7b58 100644 --- a/src/screens/PaymentScreen/PaymentScreen.test.js +++ b/src/screens/PaymentScreen/PaymentScreen.test.js @@ -80,7 +80,7 @@ describe('', () => { expect(screen.getByText(strings.payment.invalidCard)).toBeTruthy(); }); }); - it('show feedback messages about required fields when pressed pay and order button without filling inputs', async () => { + it('show feedback messages about required fields when pressed pay and order button with filling invalid inputs', async () => { renderWithProvidersAndNavigation(, { initialState, }); From 1a4deab220f49f09723f6b8c20e3d4852f9f8883 Mon Sep 17 00:00:00 2001 From: abayram <38274063+abdullahbayram@users.noreply.github.com> Date: Fri, 27 Dec 2024 00:55:22 +0100 Subject: [PATCH 7/7] test: add unit test for basketSelectors --- README.md | 2 +- src/redux/selectors/basketSelectors.test.js | 67 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/redux/selectors/basketSelectors.test.js diff --git a/README.md b/README.md index 580ae97..8ba2ef1 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Click the image above to view the demo video. - ✅ Run only necessary tests with `--findRelatedTests` flag with husky pre-commit hook. - ✅ Use aliases for paths in imports. - ✅ Generate automated test coverage reports and badges. -- ✅ Ensure test coverage remains at or above 80%. +- ✅ Ensure test coverage remains at or above 90%. --- diff --git a/src/redux/selectors/basketSelectors.test.js b/src/redux/selectors/basketSelectors.test.js new file mode 100644 index 0000000..06422d3 --- /dev/null +++ b/src/redux/selectors/basketSelectors.test.js @@ -0,0 +1,67 @@ +import { selectBasketItems, selectTotalItemCount, selectDiscount, selectTotalPrice } from './basketSelector'; + +describe('Basket Selectors', () => { + const mockState = { + basket: { + items: [ + { id: 1, name: 'Item 1', price: 50, quantity: 2 }, + { id: 2, name: 'Item 2', price: 100, quantity: 1 }, + ], + discount: 10, // 10% + }, + }; + + describe('selectBasketItems', () => { + it('should return the basket items', () => { + const result = selectBasketItems(mockState); + expect(result).toEqual(mockState.basket.items); + }); + + it('should return an empty array if basket items are undefined', () => { + const result = selectBasketItems({ basket: {} }); + expect(result).toEqual([]); + }); + }); + + describe('selectTotalItemCount', () => { + it('should calculate the total number of items in the basket', () => { + const result = selectTotalItemCount(mockState); + expect(result).toBe(3); // 2 + 1 + }); + + it('should return 0 if basket items are empty', () => { + const result = selectTotalItemCount({ basket: { items: [] } }); + expect(result).toBe(0); + }); + }); + + describe('selectDiscount', () => { + it('should return the discount value', () => { + const result = selectDiscount(mockState); + expect(result).toBe(10); // 10% + }); + + it('should return 0 if discount is undefined', () => { + const result = selectDiscount({ basket: {} }); + expect(result).toBe(0); + }); + }); + + describe('selectTotalPrice', () => { + it('should calculate the total price with the discount applied', () => { + const result = selectTotalPrice(mockState); + expect(result).toBe(180); // (50 * 2 + 100) * 0.9 + }); + + it('should return 0 if basket items are empty', () => { + const result = selectTotalPrice({ basket: { items: [], discount: 10 } }); + expect(result).toBe(0); + }); + + it('should calculate total price without discount if discount is 0', () => { + const stateWithoutDiscount = { basket: { items: mockState.basket.items, discount: 0 } }; + const result = selectTotalPrice(stateWithoutDiscount); + expect(result).toBe(200); // 50 * 2 + 100 + }); + }); +});