How to Test Custom Hook with react testing library - javascript

I tried using react-hooks-testing-library but it dosn't seem how handle hooks that use useContext.
import React,{useContext} from 'react'
import {AuthContextData} from '../../AuthContext/AuthContext'
const useAuthContext = () => {
const {authState} = useContext(AuthContextData)
const {isAuth,token,userId,userData} = authState
return {isAuth,token,userId,userData}
}
export default useAuthContext

You have to wrap your hook in a context provider:
let authContext
renderHook(() => (authContext = useAuthContext()), {
wrapper: ({ children }) => (
<AuthContextData.Provider value={/* Your value */}>
{children}
<AuthContextData.Provider>
)
})

Let's say you have a component where you call the useContext(context) hook to get a key isLoading that should be false or true.
If you want to test useContext in a component you could test it as follow:
const context = jest.spyOn(React, 'useContext');
if each test in the same file need to have different context values, then inside your test, you can mock the implementation like this:
context.mockImplementationOnce(() => {
return { isLoading: false };
});
or outside the tests for all tests to have same context:
context.mockImplementation(() => {
return { isLoading: false };
});
Hope it helps.

Related

React useEffect and React-Query useQuery issue?

I'm still new to React so forgive me if this is a silly approach to this problem.
My goal: Global error handling using a context provider and a custom hook.
The Problem: I can't remove errors without them immediately being re-added.
I display my errors via this component in the shell...
import React, { useState, useEffect } from 'react'
import Alert from '#mui/material/Alert'
import Collapse from '#mui/material/Collapse'
import { useAlertContext } from '#/context/alert-context/alert-context'
export default function AppAlert () {
const [show, setShow] = useState(false)
const alertContext = useAlertContext()
const handleClose = () => {
alertContext.remove()
setShow(false)
}
useEffect(() => {
if (alertContext.alert) {
setShow(true)
}
}, [alertContext.alert])
return (
<Collapse in={show}>
<Alert severity='error' onClose={handleClose}>
{alertContext.alert}
</Alert>
</Collapse>
)
}
I have a provider setup that also exposes a custom hook...
import React, { useState, createContext, useContext } from 'react'
const AlertContext = createContext()
const AlertProvider = ({ children }) => {
const [alert, setAlert] = useState(null)
const removeAlert = () => setAlert(null)
const addAlert = (message) => setAlert(message)
return (
<AlertContext.Provider value={{
alert,
add: addAlert,
remove: removeAlert
}}
>
{children}
</AlertContext.Provider>
)
}
const useAlertContext = () => {
return useContext(AlertContext)
}
export {
AlertProvider as default,
useAlertContext
}
And finally I have a hook setup to hit an API and call throw errors if it any occur while fetching the data. I'm purposely triggering a 404 by passing a bad API path.
import { useEffect } from 'react'
import { useQuery } from 'react-query'
import ApiV4 from '#/services/api/v4/base'
import { useAlertContext } from '#/context/alert-context/alert-context'
export const useAccess = () => {
const alertContext = useAlertContext()
const route = '/accessx'
const query = useQuery(route, async () => await ApiV4.get(route), {
retry: 0
})
useEffect(() => {
if (query.isError) {
alertContext.add(query.error.toString())
}
}, [alertContext, query.isError, query.error])
return query
}
This code seems to be the issue. Because alertContext.remove() triggers useEffect here and query.error still exists, it immediately re-adds the error to the page on remove. Removing alertContext from the array works, but it is not a real fix and linter yells.
useEffect(() => {
if (query.isError) {
alertContext.add(query.error.toString())
}
}, [alertContext, query.isError, query.error])
This is a perfectly fine approach to the problem. You've also accurately identified the problem. The solution is to create a second hook with access to the methods that will modify the context. AppAlert needs access to the data in the context, and needs to update when AlertContext.alert changes. UseAccess only needs to be able to call AlertContext.add, and that method wont change and trigger a re-render. This can be done with a second Context. You can just expose one Provider and bake the actions provider into the outer context provider.
import React, { useState, createContext, useContext } from 'react'
const AlertContext = createContext()
const AlertContextActions = createContext()
const AlertProvider = ({ children }) => {
const [alert, setAlert] = useState(null)
const removeAlert = () => setAlert(null)
const addAlert = (message) => setAlert(message)
return (
<AlertContext.Provider value={{ alert }}>
<AlertContextActions.Provider value={{ addAlert, removeAlert }}>
{children}
</AlertContextActions.Provider>
</AlertContext.Provider>
)
}
const useAlertContext = () => {
return useContext(AlertContext)
}
export {
AlertProvider as default,
useAlertContext
}
Now, where you need access to the alert you use one hook and where you need access to the actions you use the other.
// in AppAlert
import { useAlertContext, useAlertContextActions } from '#/context/alert-context/alert-context'
...
const { alert } = useAlertContext()
const { removeAlert } = useAlertContextActions()
And finally
// in useAccess
import { useAlertContextActions } from '#/context/alert-context/alert-context'
...
const { addAlert } = useAlertContextActions()
So I found a solution that seems to work for my purposes. I got a hint from this article. https://mortenbarklund.com/blog/react-architecture-provider-pattern/
Note the use of useCallback above. It ensures minimal re-renders of components using this context, as the function is guaranteed to be stable (as its memoized without dependencies).
So with this I tried the following and it solved the problem.
import React, { useState, createContext, useContext, useCallback } from 'react'
const AlertContext = createContext()
const AlertProvider = ({ children }) => {
const [alert, setAlert] = useState(null)
const removeAlert = useCallback(() => setAlert(null), [])
const addAlert = useCallback((message) => setAlert(message), [])
return (
<AlertContext.Provider value={{
alert,
add: addAlert,
remove: removeAlert
}}
>
{children}
</AlertContext.Provider>
)
}
const useAlertContext = () => {
return useContext(AlertContext)
}
export {
AlertProvider as default,
useAlertContext
}
My goal: Global error handling
One problem with the above useEffect approach is that every invocation of useAccess will run their own effects. So if you have useAccess twice on the page, and it fails, you will get two alerts, so it's not really "global".
I would encourage you to look into the global callbacks on the QueryCache in react-query. They are made for this exact use-case: To globally handle errors. Note that to use context, you would need to create the queryClient inside the Application, and make it "stable" with either useRef or useState:
function App() {
const alertContext = useAlertContext()
const [queryClient] = React.useState(() => new QueryClient({
queryCache: new QueryCache({
onError: (error) =>
alertContext.add(error.toString())
}),
}))
return (
<QueryClientProvider client={queryClient}>
<RestOfMyApp />
</QueryClientProvider>
)
}
I also have some examples in my blog.

How to use useEffect() to fetch data from an api in a useReducer() and useContext() state management setup?

reducer.js
export const reducer = (state, action) => {
switch(action.type) {
case SET_HEROES:
return { ...state, heroes: action.heroes }
}
}
AppContext.js
export const AppContext = React.createContext()
export const AppProvider = (props) => {
const initialState = {
heroes: []
}
const [appState, dispatch] = useReducer(reducer, initialState)
const setHeroes = async () => {
const result = await getHeroes()
dispatch({ type: SET_HEROES, heroes: result })
}
return <AppContext.Provider
values={{ heroes: appState.heroes, setHeroes }}
>
{props.children}
</AppContext.Provider>
}
HeroesScreen.js
const HeroesScreen = () => {
const { heroes, setHeroes } = useContext(AppContext)
useEffect(() => {
setHeroes()
}, [])
return <>
// iterate hero list
</>
}
export default HeroesScreen
Above is plain simple setup of a component using reducer + context as state management. Heroes are showing on the screen, everything works fine but I'm having a warning Reach Hook useEffect has a missing dependency: 'setHeroes'. But if I add it in as a dependency, it'll crash my app with Maximum depth update exceeded
Been searching but all I see is putting the function fetch call inside the useEffect(). What I would like is to extract the function and put it in a separate file following the SRP principle
EDITED:
As advised on using useCallback()
AppContext.js
const setHeroes = useCallback(() => {
getHeroes().then(result => dispatch({ type: SET_HEROES, heroes: result }))
}, [dispatch, getHeroes])
HeroesScreen.js
useEffect(() => {
setHeroes()
}, [setHeroes])
Adding the getHeroes as dependency on useCallback, linter shows unnecessary dependency
If you look at the code in AppProvider, you are creating a new setHeroes function every time it renders. So if you add setHeroes as a dependency to useEffect the code executes something like this:
AppProvider renders, setHeroes is created, the state is initial state
Somewhere down the component hierarchy HeroesScreen renders. useEffect is called which in turn calls setHeroes
getHeroes is called and an action is dispatched
reducer changes the state which causes AppProvider to re-render
AppProvider renders, setHeroes is created from scratch
The useEffect executes again since setHeroes changed and the whole loop repeats forever!
To fix the issue you indeed need to add setHeroes as a dependency to useEffect but then wrap it using useCallback:
const setHeroes = useCallback(async () => {
const result = await getHeroes();
dispatch({ type: SET_HEROES, heroes: result });
}, [getHeroes, dispatch]);
Good question, I also had this problem, this is my solution. I'm using Typescript but it'll work with JS only as well.
UserProvider.tsx
import * as React from 'react'
import { useReducer, useEffect } from 'react'
import UserContext from './UserContext'
import { getUser } from './actions/profile'
import userReducer, { SET_USER } from './UserReducer'
export default ({ children }: { children?: React.ReactNode }) => {
const [state, dispatch] = useReducer(userReducer, {})
const getUserData = async () => {
const { user } = await getUser()
dispatch({ type: SET_USER, payload: { ...user } })
}
useEffect(() => {
getUserData()
}, [])
return (
<UserContext.Provider value={{ user: state }}>
{children}
</UserContext.Provider>
)
}
Then wrap your App with the provider
index.tsx
<UserProvider>
<App />
</UserProvider>
Then to use the Context Consumer I do this
AnyComponent.tsx
<UserConsumer>
{({ user }) => {
...
}}
</UserConsumer
or you can also use it like this
const { user } = useContext(UserContext)
Let me know if it works.

Can I use useReducer from outside component

Now I'm trying to use useReducer to created a new way for management state and function but now found the problem is "Hooks can only be called inside of the body of a function component"
Is there any way to solve this problem?
// App Component
import React from "react";
import { product, productDis } from "./ProductReducer";
//{product} is state, {productDis} is dispatch
import { total } from "./TotalReducer";
//{total} is state and i dont need {totalDis}
const App = () => {
return (
<div>
<button onClick={()=>productDis({type:'add',payload:'pen'})}>add</button>
{product} {total}
</div>
);
};
export default App;
// ProductReducer Component
import React, { useReducer } from 'react';
import {totalDis} from './TotalReducer'
//{totalDis} is dispatch and i dont need {total}
export const [product, productDis] = useReducer((state, action) => {
switch (action.type) {
case "add": {
const product_0 = 'pencil'
const product_1 = `${action.payload} and ${product_0}`
totalDis({
type:'total_add',
payload:'250'
})
return product_1;
}
default:
return state;
}
}, []);
// TotalReducer Component
import React, { useReducer } from 'react';
export const [total, totalDis] = useReducer((total, action) => {
switch (action.type) {
case "total_add": {
const vat = action.payload*1.15
return vat;
}
default:
return total;
}
}, 0)
when i click the button on display It should be shown..." pen and pencil 287.5 "
but it show "Hooks can only be called inside of the body of a function component"
there any way to solve this problem? or i should back to nature?
React hooks should be called only inside functional components. Hook state is maintained per component instance. If hooks have to be reused, they can be extracted into custom hooks, which are functions that call built-in hooks and are supposed to be called inside functional components:
export const useTotal = () => {
const [total, totalDis] = useReducer((total, action) => {...}, 0);
...
return [total, totalDis];
};
In case there's a need to maintain common state for multiple components it should be maintained in common parent and be provided to children through props:
const Root = () => (
const [total, totalDispatcher] = useTotal();
return <App {...{total, totalDispatcher}}/>
);
const App = props => {
return (
<div>{props.total}</div>
);
};
Or context API:
const TotalContext = createContext();
const Root = () => (
<TotalContext.Provider value={useTotal()}>
<App/>
</TotalContext.Provider>
);
const App = () => {
const [total] = useContext(TotalContext);
return (
<div>{total}</div>
);
};
With useEnhancedReducer hook introduced here which returns getState function.
You will have something like.
const [state, dispatch, getState] = useEnahancedReducer(reducer, initState)
Because dispatch, getState will never change, they can be used in some hooks without their appearance in the dependence list, they can be stored somewhere else (outside of react) to to be called at anytime, from anywhere.
There is also version of useEnhancedReducer which supports adding middleware, in the same article.
From the docs,
There are three common reasons you might be seeing it:
You might have mismatching versions of React and React DOM.
You might be breaking the Rules of Hooks.
You might have more than one copy of React in the same app.
Deep drive to the docs. I hope, you'll be able to resolve the issue. Especially see:
Breaking the Rules of Hooks:
function Counter() {
// ✅ Good: top-level in a function component
const [count, setCount] = useState(0);
// ...
}
function useWindowWidth() {
// ✅ Good: top-level in a custom Hook
const [width, setWidth] = useState(window.innerWidth);
// ...
}
If you break these rules, you might see this error.
function Bad1() {
function handleClick() {
// 🔴 Bad: inside an event handler (to fix, move it outside!)
const theme = useContext(ThemeContext);
}
// ...
}
function Bad2() {
const style = useMemo(() => {
// 🔴 Bad: inside useMemo (to fix, move it outside!)
const theme = useContext(ThemeContext);
return createStyle(theme);
});
// ...
}
class Bad3 extends React.Component {
render() {
// 🔴 Bad: inside a class component
useEffect(() => {})
// ...
}
}
To conclude, your error seems to be appearing as if you're using reducer inside click handler. Check the example Bad1 to resolve your issue. What I mean here is you shouldn't be doing like this:
onClick={()=>productDis({type:'add',payload:'pen'})}
In the onClick handler, dispatch the action and inside a method use that reducer.

Pass jest.fn() function to mapDispatchToProps in enzyme shallow render

Having very simple component:
import PropTypes from 'prop-types'
import React from 'react'
import { connect } from 'react-redux'
class MyComponent extends React.Component {
componentWillMount() {
if (this.props.shouldDoSth) {
this.props.doSth()
}
}
render () {
return null
}
}
MyComponent.propTypes = {
doSth: PropTypes.func.isRequired,
shouldDoSth: PropTypes.bool.isRequired
}
const mapStateToProps = (state) => {
return {
shouldDoSth: state.shouldDoSth,
}
}
const mapDispatchToProps = (dispatch) => ({
doSth: () => console.log('you should not see me')
})
export default connect(mapStateToProps, mapDispatchToProps)(MyComponent)
I want to test if doSth is called when shouldDoSth is equal true.
I've written a test:
describe('call doSth when shouldDoSth', () => {
it('calls doSth', () => {
const doSthMock = jest.fn()
const store = mockStore({shouldDoSth: true})
shallow(<MyComponent doSth={doSthMock}/>, { context: { store } }).dive()
expect(doSthMock).toHaveBeenCalled()
})
})
but it seems that although I pass doSth as props it gets overridden by mapDispatchToProps as console.log('im not a mock') is executed.
How to properly pass/override/assign doSth function to make component use mock instead of function from mapDispatchToProps. Or maybe I'm doing something which should not be allowed at all and there is 'proper' way of testing my case. Shall I just mock dispatch instead and check if it is called with proper arguments?
I think one thing you need to figure out is whether you want doSth to be a prop, or a redux action connected in mapDispatchToProps.
If it's a prop, then you would connect it to redux in a parent (container). Remove it from this component's mapDispatchToProps. This would make the component more testable.
If you want it to be a redux action connected in this component, then it would make sense to move the action out of this component, somewhere like actions.js, import it in this component, and then mock it in the test jest.mock('actions.js', () => ({doSth: jest.mock()}))
Export the unconnected component and use it in the test and you will be able to override the mapDispatchToProps action.
export class MyComponent extends React.Component {
componentWillMount() {
if (this.props.shouldDoSth) {
this.props.doSth()
}
}
render () {
return null
}
}
MyComponent.propTypes = {
doSth: PropTypes.func.isRequired,
shouldDoSth: PropTypes.bool.isRequired
}
const mapStateToProps = (state) => {
return {
shouldDoSth: state.shouldDoSth,
}
}
const mapDispatchToProps = (dispatch) => ({
doSth: () => console.log('you should not see me')
})
export default connect(mapStateToProps, mapDispatchToProps)(MyComponent)
import {MyComponent} from '../MyComponent'
describe('call doSth when shouldDoSth', () => {
it('calls doSth', () => {
const doSthMock = jest.fn()
const store = mockStore({shouldDoSth: true})
shallow(<MyComponent doSth={doSthMock}/>, { context: { store } }).dive()
expect(doSthMock).toHaveBeenCalled()
})
})
I think that you should ask yourself if you want to test the unconnected MyComponent or the connected one.
Here you have two discussions that may help you: Am I testing connected components correclty? and Can't reference containers wrapped in a Provider or by connect with Enzyme
If you are not testing the action nor state properly said, you might forget about mapStateToProps and mapDispatchToProps (those processes are already tested by redux) and pass values through props.
Check the following example:
describe('MyComponent', () => {
let wrapper;
const doSthMock = jest.fn();
beforeEach(() => {
const componentProps = {
doSth: true,
};
wrapper = mount(
<MyComponent
{... componentProps}
doSth={doSthMock}
/>
);
});
it('+++ render the component', () => {
expect(wrapper.length).toEqual(1);
});
it('+++ call doSth when shouldDoSth', () => {
expect(doSthMock).toHaveBeenCalled();
});
})

How to send asynchronous state fetch to component?

I am currently working on a little app for fun. I ran into an issue with using axios and returning the response to my App component as updated state.
I then try to allow another component to use that piece of state, but I am not able to actually access the data. I can console.log(props) from within the List component, but I am not sure how to output the actual data as I am only able to output the promise results. I want to be able to output props.currentUser and have it be the googleId (using google Oauth2.0)..I am sure the solution is simple but, here is my code:
App.js ->
import React from 'react';
import helpers from '../helpers';
import List from './List';
class App extends React.Component{
state = {
currentUser: null
}
componentDidMount() {
this.setState(prevState => ({
currentUser: helpers.fetchUser()
}));
}
render() {
return (
<div>
<List currentUser={this.state.currentUser}/>
</div>
);
}
};
export default App;
helpers.js ->
import axios from 'axios';
var helpers = {
fetchUser: async () => {
const user = await axios.get('/api/current_user');
return user.data;
}
};
export default helpers;
List Component ->
import React from 'react';
const List = (props) => {
const renderContent = () => {
console.log(props);
return <li>{props.currentUser}</li>
}
renderContent();
return (
<div>
<h1>Grocery List</h1>
<ul>
</ul>
</div>
);
}
export default List;
Output ->
{currentUser: null}
{currentUser: Promise}
currentUser: Promise__proto__: Promise[[PromiseStatus]]: "resolved"
Because fetchUser is an async function, it returns a promise. Thus, in the App component, you have to call setState inside the .then of that promise, like so:
componentDidMount() {
helpers.fetchUser()
.then(data => {
this.setState(prevState => ({
currentUser: data
}));
});
}
Okay all you need to change is :
componentDidMount() {
this.setState(prevState => ({
currentUser: helpers.fetchUser()
}));
}
to
componentDidMount() {
helpers.fetchUser().then(data => {
this.setState(prevState => ({
currentUser: data
}));
})
}
WORKING DEMO (checkout the console)
NOTE : async await always returns the promise it just make
synchronousonus behaviour inside the async function but end ot the
function it will always returns the promise.

Categories

Resources