From eed7c411db04cbac9ea1da505383fcbad70bbb6d Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 14:32:47 +0200
Subject: [PATCH 01/13] Installs TanStack Query
---
template/package.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/template/package.json b/template/package.json
index b6e347d..878d925 100644
--- a/template/package.json
+++ b/template/package.json
@@ -24,6 +24,7 @@
"@react-navigation/native": "^6.0.10",
"@react-navigation/native-stack": "^6.6.2",
"@reduxjs/toolkit": "^1.6.1",
+ "@tanstack/react-query": "5.0.0-beta.15",
"dayjs": "^1.10.6",
"i18next": "^20.3.5",
"react": "18.2.0",
From 4601391154162b90eba16f1c649fc86bcd9f3380 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 14:35:26 +0200
Subject: [PATCH 02/13] Configures @remote alias
---
template/.prettierrc.js | 1 +
template/README.md | 1 +
template/babel.config.js | 1 +
template/tsconfig.json | 1 +
4 files changed, 4 insertions(+)
diff --git a/template/.prettierrc.js b/template/.prettierrc.js
index bd329d6..065698b 100644
--- a/template/.prettierrc.js
+++ b/template/.prettierrc.js
@@ -16,6 +16,7 @@ module.exports = {
'^@localization/(.*)$',
'^@navigation/(.*)$',
'^@redux/(.*)$',
+ '^@remote/(.*)$',
'^@screens/(.*)$',
'^@utils/(.*)$',
'^[./]',
diff --git a/template/README.md b/template/README.md
index e42c651..ed1e2b3 100644
--- a/template/README.md
+++ b/template/README.md
@@ -30,6 +30,7 @@ This app has been generated using [react-native-template-redbeard](https://githu
- `localization/` - Things related to user locale
- `navigation/` - Navigators, routes
- `redux/` - Actions, reducers, sagas
+- `remote/` - Remote state (via TanStack Query)
- `screens/` - App screens
- `utils/` - Universal helpers
diff --git a/template/babel.config.js b/template/babel.config.js
index f165bc9..c87b96f 100644
--- a/template/babel.config.js
+++ b/template/babel.config.js
@@ -31,6 +31,7 @@ module.exports = {
'@localization': './src/localization',
'@navigation': './src/navigation',
'@redux': './src/redux',
+ '@remote': './src/remote',
'@screens': './src/screens',
'@utils': './src/utils',
},
diff --git a/template/tsconfig.json b/template/tsconfig.json
index a836892..c632317 100644
--- a/template/tsconfig.json
+++ b/template/tsconfig.json
@@ -48,6 +48,7 @@
"@localization/*": ["./localization/*"],
"@navigation/*": ["./navigation/*"],
"@redux/*": ["./redux/*"],
+ "@remote/*": ["./remote/*"],
"@screens/*": ["./screens/*"],
"@utils/*": ["./utils/*"]
},
From e49848846914680faffa2dc98858074055e35153 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 14:35:55 +0200
Subject: [PATCH 03/13] Migrates logIn to query
---
template/src/App.tsx | 19 ++++++++++++-------
template/src/remote/auth.ts | 16 ++++++++++++++++
template/src/screens/LoginScreen.tsx | 12 ++++--------
3 files changed, 32 insertions(+), 15 deletions(-)
create mode 100644 template/src/remote/auth.ts
diff --git a/template/src/App.tsx b/template/src/App.tsx
index c93c04f..e295174 100644
--- a/template/src/App.tsx
+++ b/template/src/App.tsx
@@ -1,4 +1,5 @@
import { NavigationContainer } from '@react-navigation/native'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import StoreProvider from 'providers/StoreProvider'
import React from 'react'
import 'react-native-gesture-handler'
@@ -7,15 +8,19 @@ import SplashScreen from 'react-native-splash-screen'
import '@localization/i18n'
import RootStackNavigator from '@navigation/navigators/RootStackNavigator'
+const queryClient = new QueryClient()
+
const App = () => {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
)
}
diff --git a/template/src/remote/auth.ts b/template/src/remote/auth.ts
new file mode 100644
index 0000000..b73a530
--- /dev/null
+++ b/template/src/remote/auth.ts
@@ -0,0 +1,16 @@
+import { useMutation } from '@tanstack/react-query'
+import { logIn } from '@api/auth'
+import { logInAsyncSuccess } from '@api/authSlice'
+import useAppDispatch from '@hooks/useAppDispatch'
+
+export const useLogInMutation = () => {
+ const dispatch = useAppDispatch()
+
+ return useMutation({
+ mutationKey: ['auth', 'logIn'],
+ mutationFn: logIn,
+ onSuccess: tokens => {
+ dispatch(logInAsyncSuccess(tokens))
+ },
+ })
+}
diff --git a/template/src/screens/LoginScreen.tsx b/template/src/screens/LoginScreen.tsx
index b3235bd..b55b999 100644
--- a/template/src/screens/LoginScreen.tsx
+++ b/template/src/screens/LoginScreen.tsx
@@ -1,15 +1,12 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, Button, StyleSheet } from 'react-native'
-import { logInAsync, selectAuthTokens } from '@api/authSlice'
import DemoTextInput from '@components/inputs/DemoTextInput'
import MainScreenLayout from '@components/layouts/MainScreenLayout'
import DemoCard from '@components/surfaces/DemoCard'
-import useAppDispatch from '@hooks/useAppDispatch'
-import useAppSelector from '@hooks/useAppSelector'
import { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator'
import Routes from '@navigation/routes'
-import { isLoading } from '@utils/api'
+import { useLogInMutation } from '@remote/auth'
const username = 'FAKE_USERNAME'
const password = 'FAKE_PASSWORD'
@@ -17,20 +14,19 @@ const password = 'FAKE_PASSWORD'
export type LoginScreenParams = undefined
const LoginScreen: React.FC> = () => {
const { t } = useTranslation()
- const dispatch = useAppDispatch()
- const authTokensRequest = useAppSelector(selectAuthTokens)
+ const { isPending, mutate } = useLogInMutation()
return (
- {isLoading(authTokensRequest) ? (
+ {isPending ? (
) : (
From 67c7590be6324ce5bc93c57b97a379ceac686d86 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 15:10:47 +0200
Subject: [PATCH 04/13] Migrates log out to query
---
template/src/remote/auth.ts | 22 ++++++++++++++++++++++
template/src/screens/DemoScreen.tsx | 24 ++++++------------------
2 files changed, 28 insertions(+), 18 deletions(-)
diff --git a/template/src/remote/auth.ts b/template/src/remote/auth.ts
index b73a530..b507745 100644
--- a/template/src/remote/auth.ts
+++ b/template/src/remote/auth.ts
@@ -2,6 +2,9 @@ import { useMutation } from '@tanstack/react-query'
import { logIn } from '@api/auth'
import { logInAsyncSuccess } from '@api/authSlice'
import useAppDispatch from '@hooks/useAppDispatch'
+import { clearPersistence } from '@redux/persistence'
+import { resetStore } from '@redux/rootActions'
+import { persistor } from '@redux/store'
export const useLogInMutation = () => {
const dispatch = useAppDispatch()
@@ -14,3 +17,22 @@ export const useLogInMutation = () => {
},
})
}
+
+export const useLogOutMutation = () => {
+ const dispatch = useAppDispatch()
+
+ return useMutation({
+ mutationKey: ['auth', 'logOut'],
+ mutationFn: async () => {
+ Promise.resolve()
+ },
+ onSuccess: async () => {
+ // typical logout for apps that require user to be logged in
+ // before giving any further access
+ persistor.pause()
+ await clearPersistence()
+ dispatch(resetStore())
+ persistor.persist()
+ },
+ })
+}
diff --git a/template/src/screens/DemoScreen.tsx b/template/src/screens/DemoScreen.tsx
index 90a3ddd..17e5491 100644
--- a/template/src/screens/DemoScreen.tsx
+++ b/template/src/screens/DemoScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, Button, Image, StyleSheet, Text } from 'react-native'
import MainScreenLayout from '@components/layouts/MainScreenLayout'
@@ -9,9 +9,7 @@ import useAppDispatch from '@hooks/useAppDispatch'
import useAppSelector from '@hooks/useAppSelector'
import type { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator'
import Routes from '@navigation/routes'
-import { clearPersistence } from '@redux/persistence'
-import { resetStore } from '@redux/rootActions'
-import { persistor } from '@redux/store'
+import { useLogOutMutation } from '@remote/auth'
import { hasData, isNotRequested } from '@utils/api'
import {
decrementCounterBy,
@@ -29,30 +27,20 @@ interface DemoScreenProps {
}
const DemoScreen = ({ navigation }: DemoScreenProps) => {
- const [isLogoutLoading, setIsLogoutLoading] = useState(false)
const counter = useAppSelector(selectCounter)
const comicRequest = useAppSelector(selectComic)
const dispatch = useAppDispatch()
const { t } = useTranslation()
useEffect(() => {
- if (isNotRequested(comicRequest)) {
+ if (isNotRequested(comicRequest) || !hasData(comicRequest)) {
dispatch(getLatestComicAsync())
}
}, [comicRequest.state])
- const logOut = async () => {
- try {
- setIsLogoutLoading(true)
- // typical logout for apps that require user to be logged in
- // before giving any further access
- persistor.pause()
- await clearPersistence()
- dispatch(resetStore())
- persistor.persist()
- } finally {
- setIsLogoutLoading(false)
- }
+ const { mutate, isPending: isLogoutLoading } = useLogOutMutation()
+ const logOut = () => {
+ mutate()
}
const comicData = hasData(comicRequest) ? comicRequest.data : null
From edb413c157aab11f8122379093ed87ed5b233c04 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 15:29:13 +0200
Subject: [PATCH 05/13] Migrates login and logout logic from saga to query
---
template/src/api/authSlice.test.ts | 61 ++----------------------------
template/src/api/authSlice.ts | 48 +++++------------------
template/src/redux/rootSaga.ts | 4 +-
template/src/remote/auth.ts | 3 ++
4 files changed, 18 insertions(+), 98 deletions(-)
diff --git a/template/src/api/authSlice.test.ts b/template/src/api/authSlice.test.ts
index 05db540..430e51b 100644
--- a/template/src/api/authSlice.test.ts
+++ b/template/src/api/authSlice.test.ts
@@ -1,44 +1,21 @@
import { REHYDRATE } from 'redux-persist'
import { expectSaga } from 'redux-saga-test-plan'
-import * as matchers from 'redux-saga-test-plan/matchers'
-import { throwError } from 'redux-saga-test-plan/providers'
import { resetStore } from '@redux/rootActions'
-import { Failure, Loading, RemoteDataStates, Success } from '@utils/api'
-import { logIn } from './auth'
-import {
- authSlice,
- logInAsync,
- logInAsyncFailure,
- logInAsyncSuccess,
- watchAuthTokens,
- watchLogInSaga,
-} from './authSlice'
+import { authSlice, logInAsyncSuccess, watchAuthTokens } from './authSlice'
import { setAuthConfig } from './common'
const fakeAuthTokens = {
accessToken: 'FAKE_ACCESS_TOKEN',
refreshToken: 'FAKE_REFRESH_TOKEN',
}
-const fakeCredentials = { username: 'FAKE_USERNAME', password: 'FAKE_PASSWORD' }
-const logInErrorMessage = 'Login failed'
describe('#watchAuthTokens', () => {
- it('should set API auth tokens on successful login', () => {
- return expectSaga(watchAuthTokens)
- .call(setAuthConfig, fakeAuthTokens)
- .dispatch(logInAsyncSuccess(fakeAuthTokens))
- .silentRun()
- })
-
it('should restore API auth tokens on REHYDRATE auth action', () => {
const rehydrateAction = {
type: REHYDRATE,
key: 'auth',
payload: {
- tokens: {
- data: fakeAuthTokens,
- type: RemoteDataStates.SUCCESS,
- },
+ tokens: fakeAuthTokens,
_persist: {
rehydrated: true,
version: -1,
@@ -63,45 +40,13 @@ describe('#watchAuthTokens', () => {
})
})
-describe('#watchLogInSaga', () => {
- it('should log user in and put success action with tokens', () => {
- return expectSaga(watchLogInSaga)
- .provide([[matchers.call.fn(logIn), fakeAuthTokens]])
- .put(logInAsyncSuccess(fakeAuthTokens))
- .dispatch(logInAsync(fakeCredentials))
- .silentRun()
- })
-
- it('should put log in error action wit error message on auth error', () => {
- return expectSaga(watchLogInSaga)
- .provide([[matchers.call.fn(logIn), throwError(new Error(logInErrorMessage))]])
- .put(logInAsyncFailure(logInErrorMessage))
- .dispatch(logInAsync(fakeCredentials))
- .silentRun()
- })
-})
-
describe('#authSlice', () => {
const initialState = authSlice.getInitialState()
- describe('#loginAsync', () => {
- it('should change tokens state to Loading', () => {
- const state = authSlice.reducer(initialState, logInAsync(fakeCredentials))
- expect(state.tokens).toEqual(Loading)
- })
- })
-
describe('#logInAsyncSuccess', () => {
it('should store auth tokens', () => {
const state = authSlice.reducer(initialState, logInAsyncSuccess(fakeAuthTokens))
- expect(state.tokens).toEqual(Success(fakeAuthTokens))
- })
- })
-
- describe('#logInAsyncFailure', () => {
- it('should store auth error message', () => {
- const state = authSlice.reducer(initialState, logInAsyncFailure(logInErrorMessage))
- expect(state.tokens).toEqual(Failure(logInErrorMessage))
+ expect(state.tokens).toEqual(fakeAuthTokens)
})
})
})
diff --git a/template/src/api/authSlice.ts b/template/src/api/authSlice.ts
index dc9c14a..ad2daff 100644
--- a/template/src/api/authSlice.ts
+++ b/template/src/api/authSlice.ts
@@ -1,15 +1,11 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit'
import { REHYDRATE, persistReducer } from 'redux-persist'
import { PersistPartial } from 'redux-persist/es/persistReducer'
-import { call, put, takeLatest, takeLeading } from 'typed-redux-saga'
-import { logIn as logInRequest } from '@api/auth'
+import { call, takeLatest } from 'typed-redux-saga'
import { AuthTokens, setAuthConfig } from '@api/common'
-import { Credentials } from '@api/types/auth.types'
import { safeStorage } from '@redux/persistence'
import { resetStore } from '@redux/rootActions'
import { RootState } from '@redux/store'
-import { Failure, Loading, NotRequested, RemoteData, Success, isSuccess } from '@utils/api'
-import { getErrorMessage } from '@utils/error'
type TopLevelStoreStates = {
[K in keyof RootState]: RootState[K]
@@ -26,23 +22,18 @@ interface RehydrateAction {
payload?: RehydratePayload
}
-function* setApiAuthConfig(
- action: ReturnType | ReturnType | RehydrateAction,
-) {
- const isLoginAction = logInAsyncSuccess.match(action)
+function* setApiAuthConfig(action: ReturnType | RehydrateAction) {
const isResetStoreAction = resetStore.match(action)
- if (isLoginAction) {
- yield* call(setAuthConfig, action.payload)
- } else if (isResetStoreAction) {
+ if (isResetStoreAction) {
yield* call(setAuthConfig, { accessToken: undefined, refreshToken: undefined })
} else if (
action.key === authPersistConfig.key &&
action.payload &&
'tokens' in action.payload &&
- isSuccess(action.payload.tokens)
+ action.payload.tokens
) {
- yield* call(setAuthConfig, action.payload.tokens.data)
+ yield* call(setAuthConfig, action.payload.tokens)
}
}
@@ -50,50 +41,31 @@ export function* watchAuthTokens() {
yield* takeLatest([logInAsyncSuccess, REHYDRATE, resetStore], setApiAuthConfig)
}
-function* logIn(action: ReturnType) {
- try {
- const { accessToken, refreshToken } = yield* call(logInRequest, action.payload)
- yield* put(logInAsyncSuccess({ accessToken, refreshToken }))
- } catch (error) {
- yield* put(logInAsyncFailure(getErrorMessage(error)))
- }
-}
-
-export function* watchLogInSaga() {
- yield* takeLeading(logInAsync, logIn)
-}
-
interface AuthState {
- tokens: RemoteData
+ tokens: AuthTokens | undefined
}
const initialState: AuthState = {
- tokens: NotRequested,
+ tokens: undefined,
}
export const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
- logInAsync: (state, _action: PayloadAction) => {
- state.tokens = Loading
- },
logInAsyncSuccess: (state, action: PayloadAction) => {
- state.tokens = Success(action.payload)
- },
- logInAsyncFailure: (state, action: PayloadAction) => {
- state.tokens = Failure(action.payload)
+ state.tokens = action.payload
},
},
})
-export const { logInAsync, logInAsyncSuccess, logInAsyncFailure } = authSlice.actions
+export const { logInAsyncSuccess } = authSlice.actions
export const selectAuthTokens = (state: RootState) => state.auth.tokens
export const selectIsLoggedIn = (state: RootState) => {
const { tokens } = state.auth
- return isSuccess(tokens) && Boolean(tokens.data.accessToken)
+ return !!tokens?.accessToken
}
const authPersistConfig = {
diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts
index 859c6cf..1696bb2 100644
--- a/template/src/redux/rootSaga.ts
+++ b/template/src/redux/rootSaga.ts
@@ -1,7 +1,7 @@
import { all } from 'typed-redux-saga'
-import { watchAuthTokens, watchLogInSaga } from '@api/authSlice'
+import { watchAuthTokens } from '@api/authSlice'
import { watchGetLatestComicSaga } from '@screens/demoSlice'
export default function* rootSaga() {
- yield* all([watchAuthTokens(), watchLogInSaga(), watchGetLatestComicSaga()])
+ yield* all([watchAuthTokens(), watchGetLatestComicSaga()])
}
diff --git a/template/src/remote/auth.ts b/template/src/remote/auth.ts
index b507745..acbbb9a 100644
--- a/template/src/remote/auth.ts
+++ b/template/src/remote/auth.ts
@@ -1,6 +1,7 @@
import { useMutation } from '@tanstack/react-query'
import { logIn } from '@api/auth'
import { logInAsyncSuccess } from '@api/authSlice'
+import { setAuthConfig } from '@api/common'
import useAppDispatch from '@hooks/useAppDispatch'
import { clearPersistence } from '@redux/persistence'
import { resetStore } from '@redux/rootActions'
@@ -13,6 +14,7 @@ export const useLogInMutation = () => {
mutationKey: ['auth', 'logIn'],
mutationFn: logIn,
onSuccess: tokens => {
+ setAuthConfig(tokens)
dispatch(logInAsyncSuccess(tokens))
},
})
@@ -31,6 +33,7 @@ export const useLogOutMutation = () => {
// before giving any further access
persistor.pause()
await clearPersistence()
+ setAuthConfig({})
dispatch(resetStore())
persistor.persist()
},
From c92f6e0f82cd36c4ff29bf1eafe8bbeae0e77214 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 16:22:50 +0200
Subject: [PATCH 06/13] Clears query cache on log out
---
template/src/remote/auth.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/template/src/remote/auth.ts b/template/src/remote/auth.ts
index acbbb9a..df3f699 100644
--- a/template/src/remote/auth.ts
+++ b/template/src/remote/auth.ts
@@ -1,4 +1,4 @@
-import { useMutation } from '@tanstack/react-query'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { logIn } from '@api/auth'
import { logInAsyncSuccess } from '@api/authSlice'
import { setAuthConfig } from '@api/common'
@@ -22,6 +22,7 @@ export const useLogInMutation = () => {
export const useLogOutMutation = () => {
const dispatch = useAppDispatch()
+ const client = useQueryClient()
return useMutation({
mutationKey: ['auth', 'logOut'],
@@ -35,6 +36,7 @@ export const useLogOutMutation = () => {
await clearPersistence()
setAuthConfig({})
dispatch(resetStore())
+ client.clear()
persistor.persist()
},
})
From 325c72fa164c0149bd9836c849c471177bcf6785 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 17:01:12 +0200
Subject: [PATCH 07/13] Migrates getting single comic to query
---
template/src/__mocks__/fixtures.ts | 7 --
template/src/redux/rootSaga.ts | 3 +-
template/src/remote/comics.ts | 11 +++
template/src/screens/DemoScreen.test.tsx | 43 +++++++----
template/src/screens/DemoScreen.tsx | 35 +++------
.../src/screens/__tests__/demoSlice.test.ts | 76 +------------------
template/src/screens/demoSlice.ts | 41 +---------
7 files changed, 54 insertions(+), 162 deletions(-)
create mode 100644 template/src/remote/comics.ts
diff --git a/template/src/__mocks__/fixtures.ts b/template/src/__mocks__/fixtures.ts
index adea16a..e0d64be 100644
--- a/template/src/__mocks__/fixtures.ts
+++ b/template/src/__mocks__/fixtures.ts
@@ -4,10 +4,3 @@ export const comicMockResponse = {
img: 'https://imgs.xkcd.com/comics/first_internet_interaction.png',
title: 'First Internet Interaction',
}
-
-export const comicMockParsed = {
- description: comicMockResponse.alt,
- id: comicMockResponse.num,
- imageUrl: comicMockResponse.img,
- title: comicMockResponse.title,
-}
diff --git a/template/src/redux/rootSaga.ts b/template/src/redux/rootSaga.ts
index 1696bb2..451004f 100644
--- a/template/src/redux/rootSaga.ts
+++ b/template/src/redux/rootSaga.ts
@@ -1,7 +1,6 @@
import { all } from 'typed-redux-saga'
import { watchAuthTokens } from '@api/authSlice'
-import { watchGetLatestComicSaga } from '@screens/demoSlice'
export default function* rootSaga() {
- yield* all([watchAuthTokens(), watchGetLatestComicSaga()])
+ yield* all([watchAuthTokens()])
}
diff --git a/template/src/remote/comics.ts b/template/src/remote/comics.ts
new file mode 100644
index 0000000..2d71894
--- /dev/null
+++ b/template/src/remote/comics.ts
@@ -0,0 +1,11 @@
+import { useQuery } from '@tanstack/react-query'
+import { getLatestComic } from '@api/comics'
+import { mapComic } from '@api/mappers/comicMappers'
+
+export const useLatestComicQuery = () => {
+ return useQuery({
+ queryKey: ['comic', 'latest'],
+ queryFn: getLatestComic,
+ select: comicBE => mapComic(comicBE),
+ })
+}
diff --git a/template/src/screens/DemoScreen.test.tsx b/template/src/screens/DemoScreen.test.tsx
index 2932dfd..ea72aea 100644
--- a/template/src/screens/DemoScreen.test.tsx
+++ b/template/src/screens/DemoScreen.test.tsx
@@ -1,16 +1,32 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { comicMockResponse as mockComicData } from '__mocks__/fixtures'
import React from 'react'
import { TestIDs } from '@config/testIDs'
import Routes from '@navigation/routes'
-import { RemoteDataStates } from '@utils/api'
import { createNavigationProps, fireEvent, render } from '@utils/testing'
-import DemoScreen from './DemoScreen'
+import RealDemoScreen from './DemoScreen'
+
+const DemoScreen = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+ return (
+
+
+
+ )
+}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navPropsMock = createNavigationProps() as any
describe('when increment button pressed', () => {
it('should increment counter by 5', () => {
- const { getByText } = render()
+ const { getByText } = render()
const prevCounterValue = parseInt(
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
@@ -24,7 +40,7 @@ describe('when increment button pressed', () => {
describe('when decrement button pressed', () => {
it('should decrement counter by 15', () => {
- const { getByText } = render()
+ const { getByText } = render()
const prevCounterValue = parseInt(
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
@@ -38,7 +54,9 @@ describe('when decrement button pressed', () => {
describe('Comic card', () => {
describe('when comic is available', () => {
- it('renders the comic', () => {
+ it('renders the comic', async () => {
+ fetchMock.mockResponseOnce(JSON.stringify(mockComicData))
+
const comicMock = {
id: 1,
title: 'Some mock title',
@@ -48,13 +66,10 @@ describe('Comic card', () => {
const preloadedState = {
demo: {
counter: 420,
- comic: {
- state: RemoteDataStates.SUCCESS as const,
- data: comicMock,
- },
},
}
- const { getByText, getByTestId } = render(, {
+
+ const { getByText, getByTestId } = render(, {
preloadedState,
})
@@ -69,12 +84,10 @@ describe('Comic card', () => {
const preloadedState = {
demo: {
counter: 420,
- comic: {
- state: RemoteDataStates.LOADING as const,
- },
},
}
- const { getByTestId } = render(, {
+
+ const { getByTestId } = render(, {
preloadedState,
})
@@ -85,7 +98,7 @@ describe('Comic card', () => {
describe('when "go to translations demo" pressed', () => {
it('should navigate to translations demo screen', () => {
- const { getByText } = render()
+ const { getByText } = render()
fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/))
expect(navPropsMock.navigation.navigate).toBeCalledWith(Routes.TRANSLATIONS_DEMO_SCREEN)
diff --git a/template/src/screens/DemoScreen.tsx b/template/src/screens/DemoScreen.tsx
index 17e5491..bebbf62 100644
--- a/template/src/screens/DemoScreen.tsx
+++ b/template/src/screens/DemoScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect } from 'react'
+import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, Button, Image, StyleSheet, Text } from 'react-native'
import MainScreenLayout from '@components/layouts/MainScreenLayout'
@@ -10,14 +10,8 @@ import useAppSelector from '@hooks/useAppSelector'
import type { RootStackScreenProps } from '@navigation/navigators/RootStackNavigator'
import Routes from '@navigation/routes'
import { useLogOutMutation } from '@remote/auth'
-import { hasData, isNotRequested } from '@utils/api'
-import {
- decrementCounterBy,
- getLatestComicAsync,
- incrementCounterBy,
- selectComic,
- selectCounter,
-} from './demoSlice'
+import { useLatestComicQuery } from '@remote/comics'
+import { decrementCounterBy, incrementCounterBy, selectCounter } from './demoSlice'
export type DemoScreenParams = undefined
@@ -28,22 +22,12 @@ interface DemoScreenProps {
const DemoScreen = ({ navigation }: DemoScreenProps) => {
const counter = useAppSelector(selectCounter)
- const comicRequest = useAppSelector(selectComic)
const dispatch = useAppDispatch()
const { t } = useTranslation()
- useEffect(() => {
- if (isNotRequested(comicRequest) || !hasData(comicRequest)) {
- dispatch(getLatestComicAsync())
- }
- }, [comicRequest.state])
+ const { isLoading, data: comicData } = useLatestComicQuery()
- const { mutate, isPending: isLogoutLoading } = useLogOutMutation()
- const logOut = () => {
- mutate()
- }
-
- const comicData = hasData(comicRequest) ? comicRequest.data : null
+ const { mutate: logOut, isPending: isLogoutLoading } = useLogOutMutation()
return (
@@ -59,7 +43,7 @@ const DemoScreen = ({ navigation }: DemoScreenProps) => {
{`${t('demoScreen.counter')} ${counter}`}
- {comicData ? (
+ {!isLoading && comicData ? (
<>
{comicData.title}
{
{isLogoutLoading ? (
) : (
-
+
diff --git a/template/src/screens/__tests__/demoSlice.test.ts b/template/src/screens/__tests__/demoSlice.test.ts
index 8b7a54f..ad86319 100644
--- a/template/src/screens/__tests__/demoSlice.test.ts
+++ b/template/src/screens/__tests__/demoSlice.test.ts
@@ -1,38 +1,6 @@
-import { comicMockParsed, comicMockResponse } from '__mocks__/fixtures'
-import { expectSaga } from 'redux-saga-test-plan'
-import * as matchers from 'redux-saga-test-plan/matchers'
-import { throwError } from 'redux-saga-test-plan/providers'
-import {
- decrementCounterBy,
- demoSlice,
- fetchLatestComic,
- getLatestComicAsync,
- getLatestComicAsyncFailure,
- getLatestComicAsyncSuccess,
- incrementCounterBy,
-} from '@screens/demoSlice'
-import { getLatestComic } from '../../api/comics'
-import { Failure, Loading, Refreshing, Success } from '../../utils/api'
+import { decrementCounterBy, demoSlice, incrementCounterBy } from '@screens/demoSlice'
describe('DemoSlice', () => {
- describe('Demo saga fetchLatestComic', () => {
- it('dispatches success action when the request succeed', () => {
- return expectSaga(fetchLatestComic)
- .provide([[matchers.call.fn(getLatestComic), comicMockResponse]])
- .put(getLatestComicAsyncSuccess(comicMockParsed))
- .run()
- })
-
- it('dispatches error action when the request fails', () => {
- const error = new Error('Demo error')
-
- return expectSaga(fetchLatestComic)
- .provide([[matchers.call.fn(getLatestComic), throwError(error)]])
- .put(getLatestComicAsyncFailure(error.message))
- .run()
- })
- })
-
const initialState = demoSlice.getInitialState()
describe('Demo reducer', () => {
@@ -58,47 +26,5 @@ describe('DemoSlice', () => {
expect(result.counter).toEqual(410)
})
-
- it('set getLatestComicAsync to Loading if there is no comic in store', () => {
- const result = demoSlice.reducer(initialState, {
- type: getLatestComicAsync,
- payload: comicMockResponse,
- })
-
- expect(result.comic).toEqual(Loading)
- })
-
- it('set getLatestComicAsync to Refreshing if there is already comic in store', () => {
- const newInitialState = {
- ...initialState,
- comic: Success(comicMockParsed),
- }
-
- const result = demoSlice.reducer(newInitialState, {
- type: getLatestComicAsync,
- payload: comicMockResponse,
- })
-
- expect(result.comic).toEqual(Refreshing(comicMockParsed))
- })
-
- it('set getLatestComicAsyncSuccess state', () => {
- const result = demoSlice.reducer(initialState, {
- type: getLatestComicAsyncSuccess,
- payload: comicMockResponse,
- })
-
- expect(result.comic).toEqual(Success(comicMockResponse))
- })
-
- it('set getLatestComicAsyncFailure state', () => {
- const error = 'Error message'
- const result = demoSlice.reducer(initialState, {
- type: getLatestComicAsyncFailure,
- payload: error,
- })
-
- expect(result.comic).toEqual(Failure(error))
- })
})
})
diff --git a/template/src/screens/demoSlice.ts b/template/src/screens/demoSlice.ts
index 04afb03..464f0ca 100644
--- a/template/src/screens/demoSlice.ts
+++ b/template/src/screens/demoSlice.ts
@@ -1,34 +1,12 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit'
-import { call, put, takeEvery } from 'typed-redux-saga'
-import { getLatestComic } from '@api/comics'
-import { mapComic } from '@api/mappers/comicMappers'
-import { Comic } from '@api/types/comic.types'
import { RootState } from '@redux/store'
-import { Failure, NotRequested, Pending, RemoteData, Success } from '@utils/api'
-import { getErrorMessage } from '@utils/error'
-
-export function* fetchLatestComic() {
- try {
- const comic = yield* call(getLatestComic)
- yield* put(getLatestComicAsyncSuccess(mapComic(comic)))
- } catch (error) {
- const errorMessage = getErrorMessage(error)
- yield* put(getLatestComicAsyncFailure(errorMessage))
- }
-}
-
-export function* watchGetLatestComicSaga() {
- yield* takeEvery(getLatestComicAsync, fetchLatestComic)
-}
interface DemoState {
counter: number
- comic: RemoteData
}
const initialState: DemoState = {
counter: 420,
- comic: NotRequested,
}
export const demoSlice = createSlice({
@@ -41,28 +19,11 @@ export const demoSlice = createSlice({
decrementCounterBy: (state, action: PayloadAction) => {
state.counter -= action.payload
},
- getLatestComicAsync: state => {
- state.comic = Pending(state.comic)
- },
- getLatestComicAsyncSuccess: (state, action: PayloadAction) => {
- state.comic = Success(action.payload)
- },
- getLatestComicAsyncFailure: (state, action: PayloadAction) => {
- state.comic = Failure(action.payload)
- },
},
})
-export const {
- incrementCounterBy,
- decrementCounterBy,
- getLatestComicAsync,
- getLatestComicAsyncSuccess,
- getLatestComicAsyncFailure,
-} = demoSlice.actions
+export const { incrementCounterBy, decrementCounterBy } = demoSlice.actions
export const selectCounter = (state: RootState) => state.demo.counter
-export const selectComic = (state: RootState) => state.demo.comic
-
export default demoSlice.reducer
From 5ae505431f25f4310ba8256e5ce633e2b0467482 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 23:17:04 +0200
Subject: [PATCH 08/13] Removes api utils
---
template/src/utils/api.ts | 87 ---------------------------------------
1 file changed, 87 deletions(-)
delete mode 100644 template/src/utils/api.ts
diff --git a/template/src/utils/api.ts b/template/src/utils/api.ts
deleted file mode 100644
index 654a3b3..0000000
--- a/template/src/utils/api.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-export enum RemoteDataStates {
- NOT_REQUESTED = 'NOT_REQUESTED',
- LOADING = 'LOADING',
- REFRESHING = 'REFRESHING',
- FAILURE = 'FAILURE',
- SUCCESS = 'SUCCESS',
-}
-
-type NotRequestedType = {
- state: RemoteDataStates.NOT_REQUESTED
-}
-
-type LoadingType = {
- state: RemoteDataStates.LOADING
-}
-
-type RefreshingType = {
- state: RemoteDataStates.REFRESHING
- data: T
-}
-
-type FailureType = {
- state: RemoteDataStates.FAILURE
- error: E
- data?: T
-}
-
-type SuccessType = {
- state: RemoteDataStates.SUCCESS
- data: T
-}
-
-// Constructors
-
-export const NotRequested: NotRequestedType = {
- state: RemoteDataStates.NOT_REQUESTED,
-}
-
-export const Loading: LoadingType = { state: RemoteDataStates.LOADING }
-
-export const Refreshing = (data: T): RefreshingType => ({
- state: RemoteDataStates.REFRESHING,
- data,
-})
-
-export const Pending = (remoteData: RemoteData) => {
- return hasData(remoteData) ? Refreshing(remoteData.data) : Loading
-}
-
-export const Failure = (error: E, data?: T): FailureType => ({
- state: RemoteDataStates.FAILURE,
- error,
- ...(data && { data }),
-})
-
-export const Success = (data: T): SuccessType => ({
- state: RemoteDataStates.SUCCESS,
- data,
-})
-
-export type RemoteData =
- | NotRequestedType
- | LoadingType
- | RefreshingType
- | FailureType
- | SuccessType
-
-export const isNotRequested = (
- remoteData: RemoteData,
-): remoteData is NotRequestedType => remoteData.state === RemoteDataStates.NOT_REQUESTED
-
-export const isLoading = (remoteData: RemoteData): remoteData is LoadingType =>
- remoteData.state === RemoteDataStates.LOADING
-
-export const isRefreshing = (remoteData: RemoteData): remoteData is RefreshingType =>
- remoteData.state === RemoteDataStates.REFRESHING
-
-export const isFailure = (remoteData: RemoteData): remoteData is FailureType =>
- remoteData.state === RemoteDataStates.FAILURE
-
-export const isSuccess = (remoteData: RemoteData): remoteData is SuccessType =>
- remoteData.state === RemoteDataStates.SUCCESS
-
-export const hasData = (
- remoteData: RemoteData,
-): remoteData is SuccessType | RefreshingType | Required> =>
- !!(remoteData as SuccessType | RefreshingType | Required>).data
From cc05b21aa2a188dedfb57ae37e8f6aa9d5047fa6 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Mon, 14 Aug 2023 23:25:47 +0200
Subject: [PATCH 09/13] Moves setting provider in tests to helper
---
template/src/screens/DemoScreen.test.tsx | 28 +++++-------------------
template/src/utils/testing.tsx | 15 ++++++++++++-
2 files changed, 20 insertions(+), 23 deletions(-)
diff --git a/template/src/screens/DemoScreen.test.tsx b/template/src/screens/DemoScreen.test.tsx
index ea72aea..9325ae6 100644
--- a/template/src/screens/DemoScreen.test.tsx
+++ b/template/src/screens/DemoScreen.test.tsx
@@ -1,32 +1,16 @@
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { comicMockResponse as mockComicData } from '__mocks__/fixtures'
import React from 'react'
import { TestIDs } from '@config/testIDs'
import Routes from '@navigation/routes'
import { createNavigationProps, fireEvent, render } from '@utils/testing'
-import RealDemoScreen from './DemoScreen'
-
-const DemoScreen = () => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
- })
- return (
-
-
-
- )
-}
+import DemoScreen from './DemoScreen'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navPropsMock = createNavigationProps() as any
describe('when increment button pressed', () => {
it('should increment counter by 5', () => {
- const { getByText } = render()
+ const { getByText } = render()
const prevCounterValue = parseInt(
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
@@ -40,7 +24,7 @@ describe('when increment button pressed', () => {
describe('when decrement button pressed', () => {
it('should decrement counter by 15', () => {
- const { getByText } = render()
+ const { getByText } = render()
const prevCounterValue = parseInt(
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
@@ -69,7 +53,7 @@ describe('Comic card', () => {
},
}
- const { getByText, getByTestId } = render(, {
+ const { getByText, getByTestId } = render(, {
preloadedState,
})
@@ -87,7 +71,7 @@ describe('Comic card', () => {
},
}
- const { getByTestId } = render(, {
+ const { getByTestId } = render(, {
preloadedState,
})
@@ -98,7 +82,7 @@ describe('Comic card', () => {
describe('when "go to translations demo" pressed', () => {
it('should navigate to translations demo screen', () => {
- const { getByText } = render()
+ const { getByText } = render()
fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/))
expect(navPropsMock.navigation.navigate).toBeCalledWith(Routes.TRANSLATIONS_DEMO_SCREEN)
diff --git a/template/src/utils/testing.tsx b/template/src/utils/testing.tsx
index 375b36d..1e00200 100644
--- a/template/src/utils/testing.tsx
+++ b/template/src/utils/testing.tsx
@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RenderOptions, render as rtlRender } from '@testing-library/react-native'
import React from 'react'
import { Provider } from 'react-redux'
@@ -19,7 +20,19 @@ function render(
}: Options = {},
) {
function Wrapper({ children }: { children: JSX.Element }) {
- return {children}
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+
+ return (
+
+ {children}
+
+ )
}
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
}
From 396aa558f87188a2164e2ff36fe8b90f6eb2a5b0 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Fri, 25 Aug 2023 12:41:00 +0200
Subject: [PATCH 10/13] Updates testing libraries
---
template/package.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/template/package.json b/template/package.json
index 878d925..43bfb91 100644
--- a/template/package.json
+++ b/template/package.json
@@ -54,8 +54,8 @@
"@babel/runtime": "^7.20.0",
"@jambit/eslint-plugin-typed-redux-saga": "^0.4.0",
"@react-native-community/eslint-config": "^3.2.0",
- "@testing-library/jest-native": "^4.0.1",
- "@testing-library/react-native": "^7.2.0",
+ "@testing-library/jest-native": "^5.4.2",
+ "@testing-library/react-native": "^12.2.2",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@tsconfig/react-native": "^2.0.2",
"@types/jest": "^29.2.1",
From af15f6bf499b83cd92fcd6c343cb1b4082922fc7 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Fri, 25 Aug 2023 13:29:03 +0200
Subject: [PATCH 11/13] Updates tests for DemoScreen
---
template/src/__mocks__/fixtures.ts | 7 ++++
template/src/screens/DemoScreen.test.tsx | 51 +++++++++++++++++-------
2 files changed, 44 insertions(+), 14 deletions(-)
diff --git a/template/src/__mocks__/fixtures.ts b/template/src/__mocks__/fixtures.ts
index e0d64be..adea16a 100644
--- a/template/src/__mocks__/fixtures.ts
+++ b/template/src/__mocks__/fixtures.ts
@@ -4,3 +4,10 @@ export const comicMockResponse = {
img: 'https://imgs.xkcd.com/comics/first_internet_interaction.png',
title: 'First Internet Interaction',
}
+
+export const comicMockParsed = {
+ description: comicMockResponse.alt,
+ id: comicMockResponse.num,
+ imageUrl: comicMockResponse.img,
+ title: comicMockResponse.title,
+}
diff --git a/template/src/screens/DemoScreen.test.tsx b/template/src/screens/DemoScreen.test.tsx
index 9325ae6..f559f41 100644
--- a/template/src/screens/DemoScreen.test.tsx
+++ b/template/src/screens/DemoScreen.test.tsx
@@ -1,13 +1,26 @@
-import { comicMockResponse as mockComicData } from '__mocks__/fixtures'
+import { comicMockParsed } from '__mocks__/fixtures'
import React from 'react'
import { TestIDs } from '@config/testIDs'
import Routes from '@navigation/routes'
-import { createNavigationProps, fireEvent, render } from '@utils/testing'
+import * as remoteComics from '@remote/comics'
+import { act, createNavigationProps, fireEvent, render } from '@utils/testing'
import DemoScreen from './DemoScreen'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navPropsMock = createNavigationProps() as any
+const mockUseLatestComicQuery = jest.spyOn(remoteComics, 'useLatestComicQuery').mockReturnValue({
+ isLoading: false,
+ data: undefined,
+} as ReturnType)
+
+jest.mock('@remote/auth', () => ({
+ useLogOutMutation: jest.fn().mockImplementation(() => ({
+ mutate: jest.fn(),
+ isPending: false,
+ })),
+}))
+
describe('when increment button pressed', () => {
it('should increment counter by 5', () => {
const { getByText } = render()
@@ -15,7 +28,10 @@ describe('when increment button pressed', () => {
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
)
- fireEvent.press(getByText(/demoScreen.incrementButton/))
+ act(() => {
+ fireEvent.press(getByText(/demoScreen.incrementButton/))
+ })
+
const counterValue = parseInt(getByText(/demoScreen.counter/).props.children.split(' ')[1], 10)
expect(counterValue).toBe(prevCounterValue + 5)
@@ -29,7 +45,10 @@ describe('when decrement button pressed', () => {
getByText(/demoScreen.counter/).props.children.split(' ')[1],
10,
)
- fireEvent.press(getByText(/demoScreen.decrementButton/))
+
+ act(() => {
+ fireEvent.press(getByText(/demoScreen.decrementButton/))
+ })
const counterValue = parseInt(getByText(/demoScreen.counter/).props.children.split(' ')[1], 10)
expect(counterValue).toBe(prevCounterValue - 15)
@@ -39,14 +58,11 @@ describe('when decrement button pressed', () => {
describe('Comic card', () => {
describe('when comic is available', () => {
it('renders the comic', async () => {
- fetchMock.mockResponseOnce(JSON.stringify(mockComicData))
+ mockUseLatestComicQuery.mockReturnValueOnce({
+ isLoading: false,
+ data: comicMockParsed,
+ } as ReturnType)
- const comicMock = {
- id: 1,
- title: 'Some mock title',
- imageUrl: 'http://example.com/test.jpg',
- description: 'Some mock description',
- }
const preloadedState = {
demo: {
counter: 420,
@@ -57,13 +73,18 @@ describe('Comic card', () => {
preloadedState,
})
- expect(getByText(comicMock.title)).toBeDefined()
- expect(getByText(comicMock.description)).toBeDefined()
+ expect(getByText(comicMockParsed.title)).toBeDefined()
+ expect(getByText(comicMockParsed.description)).toBeDefined()
expect(getByTestId(TestIDs.DEMO_COMIC_IMAGE)).toBeDefined()
})
})
describe('when NO comic is available', () => {
+ mockUseLatestComicQuery.mockReturnValue({
+ isLoading: true,
+ data: undefined,
+ } as ReturnType)
+
it('renders the loading spinner', () => {
const preloadedState = {
demo: {
@@ -83,7 +104,9 @@ describe('Comic card', () => {
describe('when "go to translations demo" pressed', () => {
it('should navigate to translations demo screen', () => {
const { getByText } = render()
- fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/))
+ act(() => {
+ fireEvent.press(getByText(/demoScreen.goToTranslationsDemo/))
+ })
expect(navPropsMock.navigation.navigate).toBeCalledWith(Routes.TRANSLATIONS_DEMO_SCREEN)
})
From d63ff9f3527e43e48f47cd02a41347228969e13c Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Fri, 1 Sep 2023 17:15:41 +0200
Subject: [PATCH 12/13] Covers comics hook with tests
---
template/src/remote/comics.test.ts | 64 ++++++++++++++++++++++++++++++
template/src/utils/testing.tsx | 31 ++++++++++-----
2 files changed, 85 insertions(+), 10 deletions(-)
create mode 100644 template/src/remote/comics.test.ts
diff --git a/template/src/remote/comics.test.ts b/template/src/remote/comics.test.ts
new file mode 100644
index 0000000..db897f6
--- /dev/null
+++ b/template/src/remote/comics.test.ts
@@ -0,0 +1,64 @@
+import { renderHook, waitFor } from '@testing-library/react-native'
+import { getLatestComic } from '@api/comics'
+import { Comic, ComicBE } from '@api/types/comic.types'
+import { createTestEnvWrapper } from '@utils/testing'
+import { useLatestComicQuery } from './comics'
+
+const mockComicBE: ComicBE = {
+ num: 1,
+ title: 'test title',
+ alt: 'test description',
+ img: 'test image url',
+} as ComicBE
+
+const mockComic: Comic = {
+ id: 1,
+ title: 'test title',
+ description: 'test description',
+ imageUrl: 'test image url',
+}
+
+const mockGetLatestComic = getLatestComic as jest.MockedFunction
+
+jest.mock('@api/comics', () => ({
+ getLatestComic: jest.fn().mockRejectedValue(mockComicBE),
+}))
+
+jest.useFakeTimers()
+
+describe('comics', () => {
+ let wrapper: ReturnType
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ wrapper = createTestEnvWrapper({})
+ })
+
+ describe('useLatestComicQuery', () => {
+ it('is initially loading', async () => {
+ const { result } = renderHook(() => useLatestComicQuery(), { wrapper })
+
+ expect(result.current).toMatchObject({
+ isLoading: true,
+ isSuccess: false,
+ data: undefined,
+ })
+ })
+
+ it('should fetch the latest comic', async () => {
+ mockGetLatestComic.mockResolvedValueOnce(mockComicBE)
+
+ const { result } = renderHook(() => useLatestComicQuery(), { wrapper })
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(getLatestComic).toHaveBeenCalledTimes(1)
+
+ expect(result.current).toMatchObject({
+ data: mockComic,
+ isLoading: false,
+ isSuccess: true,
+ })
+ })
+ })
+})
diff --git a/template/src/utils/testing.tsx b/template/src/utils/testing.tsx
index 1e00200..a598b24 100644
--- a/template/src/utils/testing.tsx
+++ b/template/src/utils/testing.tsx
@@ -11,15 +11,11 @@ interface Options extends RenderOptions {
store?: ReturnType
}
-function render(
- ui: JSX.Element,
- {
- preloadedState,
- store = configureStore({ reducer, preloadedState }),
- ...renderOptions
- }: Options = {},
-) {
- function Wrapper({ children }: { children: JSX.Element }) {
+export const createTestEnvWrapper = ({
+ preloadedState = {},
+ store = configureStore({ reducer, preloadedState }),
+}: Options) => {
+ const TestEnvWrapper = ({ children }: { children: JSX.Element }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@@ -34,7 +30,22 @@ function render(
)
}
- return rtlRender(ui, { wrapper: Wrapper, ...renderOptions })
+
+ return TestEnvWrapper
+}
+
+function render(
+ ui: JSX.Element,
+ {
+ preloadedState,
+ store = configureStore({ reducer, preloadedState }),
+ ...renderOptions
+ }: Options = {},
+) {
+ return rtlRender(ui, {
+ wrapper: createTestEnvWrapper({ preloadedState, store }),
+ ...renderOptions,
+ })
}
export * from '@testing-library/react-native'
export { render }
From 8be58796987b1d53a84c32a98735b15e0106edf2 Mon Sep 17 00:00:00 2001
From: Szymon Koper <2790570+szymonkoper@users.noreply.github.com>
Date: Fri, 1 Sep 2023 18:36:27 +0200
Subject: [PATCH 13/13] Covers auth hook with tests
---
template/src/remote/auth.test.ts | 101 +++++++++++++++++++++++++++++++
1 file changed, 101 insertions(+)
create mode 100644 template/src/remote/auth.test.ts
diff --git a/template/src/remote/auth.test.ts b/template/src/remote/auth.test.ts
new file mode 100644
index 0000000..50d6e14
--- /dev/null
+++ b/template/src/remote/auth.test.ts
@@ -0,0 +1,101 @@
+import { renderHook, waitFor } from '@testing-library/react-native'
+import { logIn } from '@api/auth'
+import { logInAsyncSuccess } from '@api/authSlice'
+import { setAuthConfig } from '@api/common'
+import * as persistence from '@redux/persistence'
+import { resetStore } from '@redux/rootActions'
+import { persistor } from '@redux/store'
+import { createTestEnvWrapper } from '@utils/testing'
+import { useLogInMutation, useLogOutMutation } from './auth'
+
+const mockCredentials = {
+ username: 'testUsername',
+ password: 'testPassword',
+}
+
+const mockTokens = {
+ accessToken: 'testAccessToken',
+ refreshToken: 'testRefreshToken',
+}
+
+jest.mock('@api/auth', () => ({
+ logIn: jest.fn(),
+}))
+const mockLogIn = logIn as jest.MockedFunction
+mockLogIn.mockResolvedValue(mockTokens)
+
+jest.mock('@api/common', () => ({
+ setAuthConfig: jest.fn(),
+}))
+const mockSetAuthConfig = setAuthConfig as jest.MockedFunction
+
+const mockDispatch = jest.fn()
+jest.mock('@hooks/useAppDispatch', () => ({
+ __esModule: true,
+ default: jest.fn(() => {
+ return mockDispatch
+ }),
+}))
+
+jest.mock('@redux/store', () => ({
+ persistor: {
+ pause: jest.fn(),
+ persist: jest.fn(),
+ },
+}))
+
+jest.useFakeTimers()
+
+describe('auth', () => {
+ let wrapper: ReturnType
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ wrapper = createTestEnvWrapper({})
+ })
+
+ describe('useLogInMutation', () => {
+ it('calls onSuccess', async () => {
+ const { result } = renderHook(() => useLogInMutation(), { wrapper })
+
+ result.current.mutate(mockCredentials)
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(result.current).toMatchObject({
+ isSuccess: true,
+ data: mockTokens,
+ })
+
+ expect(mockSetAuthConfig).toHaveBeenCalledTimes(1)
+ expect(mockSetAuthConfig).toHaveBeenLastCalledWith(mockTokens)
+ expect(mockDispatch).toHaveBeenCalledTimes(1)
+ expect(mockDispatch).toHaveBeenLastCalledWith(logInAsyncSuccess(mockTokens))
+ })
+ })
+
+ describe('useLogOutMutation', () => {
+ it('calls onSuccess', async () => {
+ const mockClearPersistence = jest.spyOn(persistence, 'clearPersistence')
+
+ const { result } = renderHook(() => useLogOutMutation(), { wrapper })
+
+ result.current.mutate()
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true))
+
+ expect(result.current).toMatchObject({
+ isSuccess: true,
+ data: undefined,
+ })
+
+ expect(persistor.pause).toHaveBeenCalledTimes(1)
+ expect(mockClearPersistence).toHaveBeenCalledTimes(1)
+ expect(mockSetAuthConfig).toHaveBeenCalledTimes(1)
+ expect(mockSetAuthConfig).toHaveBeenLastCalledWith({})
+ expect(mockDispatch).toHaveBeenCalledTimes(1)
+ expect(mockDispatch).toHaveBeenLastCalledWith(resetStore())
+ expect(persistor.persist).toHaveBeenCalledTimes(1)
+ })
+ })
+})