i am trying to test if a like counter has been increased after user clicks a button. I am using react testing library where i find a button and preform userEvent.click this should dispatch an action behind the scenes and increment the counter where i can then assert the new value.
This works when i manually go through the ui but cannot get the test to work.
Button:
<Button
size="small"
color="primary"
onClick={() => dispatch(likePosts(post._id))}
>
<ThumbUpIcon fontSize="small" />
Like {`${post.likeCount}`}
{}
</Button>
Thunk action:
export const likePosts = (id) => async (dispatch) => {
try {
const { data } = await api.likePost(id);
dispatch({ type: LIKE, payload: data });
} catch (error) {
console.log(error);
}
I have also set up a test-util to help me test connected component TEST UTIL LINK
I have also added applyMiddleware(thunk) to support thunk when a for a connected component
test-util:
import React from "react";
import { render as rtlRender } from "#testing-library/react";
import { legacy_createStore, applyMiddleware } from "redux";
import { Provider } from "react-redux";
import thunk from "redux-thunk";
// Replace this with the appropriate imports for your project
import reducers from "../redux/reducers";
const render = (
ui,
{
store = legacy_createStore(reducers, applyMiddleware(thunk)),
...renderOptions
} = {}
) => {
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
);
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
};
export * from "#testing-library/react";
export * from "#testing-library/jest-dom";
// override render method
export { render };
My Test:
import Post from "./Post";
import { render, screen } from "../../../utils/test-utils";
import userEvent from "#testing-library/user-event";
describe("Post", () => {
let initialState;
beforeEach(() => {
initialState = {
_id: "1234",
title: "post title",
message: "post message",
creator: "post creator",
tags: ["postTag", "postTag"],
selectedFile: "path/to/file",
likeCount: 0,
createdAt: "2022-07-20T23:54:25.251Z",
};
});
test("should increment post likes when like button clicked", () => {
render(<Post post={initialState} />, { initialState });
const postLikeButton = screen.getByRole("button", { name: /Like/i });
userEvent.click(postLikeButton);
const clickedPostLikeButton = screen.getByRole("button", {
name: /Like 1/i,
}).textContent;
// expect().toHaveBeenCalled();
expect(clickedPostLikeButton).toBe(100);
});
});
Test error:
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/Like 1/i`
That would suggest that the the action was not dispatched when then button was clicked in the test.
UPDATE:
The button is from MUI library:
import { Button } from "#material-ui/core";
The post prop is passed from its parent component Posts:
import React from "react";
import { useSelector } from "react-redux";
import { Grid, CircularProgress } from "#material-ui/core";
import Post from "./Post/Post";
import useStyles from "./styles";
const Posts = ({ setCurrentId }) => {
const posts = useSelector((state) => state.posts);
const classes = useStyles();
return !posts.length ? (
<CircularProgress />
) : (
<Grid
className={classes.container}
container
alignItems="stretch"
spacing={3}
>
{posts.map((post, index) => (
<Grid key={index} item xs={12} sm={6}>
<Post key={post.id} post={post} setCurrentId={setCurrentId} />
</Grid>
))}
</Grid>
);
};
export default Posts;
Also all of this works just fine when using the UI, its just in react testing library test the button onClick seems the not to dispatch likePosts
Did you try with redux-mock-store?
import configureStore from 'redux-mock-store'
const mockStore = configureStore()
const store = mockStore(reducers) // add your reducers here
// ...
render(
<Provider store={store}>
{children}
</Provider>
)
Related
I have a navbar component containing a dropdown which allows user to change their "region" and it updates the redux store. The update to redux store is working properly. However, it is not re-rendering the parent component.
From this navbar component I use the changeRegion ActionCreator to update the store. I can see the update in localstorage and can even console.log it and see the proper region after its changed. What is NOT happening, is the table in the "UserManagement" component is not updating.
I use setPageData in useEffect() in UserManagement.js to determine what state the table is in. Basically, if there is no region selected (initialState is blank) then it should show an empty table with a dropdown to select region. Once a region is selected, it should then display the data in table.
Clearly I am just missing something, but I have been trying to make this work for way too long and really could use some help.
UserManagement.js
import React, { useState, useEffect } from "react";
import {
Box,
ButtonDropdown,
Table,
} from "private-compontent";
import fakeData from "../fakeData"
import { useSelector, useDispatch } from 'react-redux'
import { bindActionCreators } from 'redux'
import { actionCreators } from '../../../state/actionsCreators'
export const DetailsTable = (props) => {
const [pageData, setPageData] = useState([]);
const region = useSelector((state) => state.region)
const dispatch = useDispatch();
const { changeRegion } = bindActionCreators(actionCreators, dispatch)
const [regionState, setRegionState] = useState(region);
const currentUserRegion = region === '' ? "Select Region" : "Region: " + region;
useEffect(() => {
if (region && region !== '') {
setRegionState(region)
setPageData(fakeData);
} else {
setPageData([{}]);
}
}, []);
// When a new region is selected, save it to localStorage and setRegion
const regionChange = (event) => {
changeRegion(event.detail.id)
}
function EmptyState({ title, subtitle, action }) {
return (
<Box textAlign="center" color="inherit">
<Box variant="strong" textAlign="center" color="inherit">
{title}
</Box>
<Box variant="p" padding={{ bottom: 's' }} color="inherit">
{subtitle}
</Box>
{action}
</Box>
);
}
return (
<>
<Table
{...collectionProps}
items={ items }
empty={
<EmptyState
title="No Data"
subtitle="No data to display"
action={
<ButtonDropdown
items={[
{ text: "NA", id: "NA", disabled: false },
{ text: "EU", id: "EU", disabled: false },
{ text: "FE", id: "FE", disabled: false },
]}
onItemClick={regionChange}
>
{currentUserRegion}
</ButtonDropdown> }
/>
}
/>
</>
);
};
export default DetailsTable;
Navbar.js
import React from "react";
import { ButtonDropdown} from "../ButtonDropdown";
import { useSelector, useDispatch } from 'react-redux'
import { bindActionCreators } from 'redux'
import { actionCreators } from '../../../state/actionsCreators'
export const Navbar = () => {
const region = useSelector((state) => state.region)
const dispatch = useDispatch();
const { changeRegion } = bindActionCreators(actionCreators, dispatch)
// When a new region is selected, save it to localStorage and setRegion
const regionChange = (event) => {
changeRegion(event.detail.id)
}
const currentUserRegion = region === '' ? "Select Region" : "Region: " + region;
// Construct the ButtonDropdown based on the currentPath
const regionButtonDropdown =
// If UserManagement show region options
<ButtonDropdown
items={[
{ text: "NA", id: "NA", disabled: false },
{ text: "EU", id: "EU", disabled: false },
{ text: "FE", id: "FE", disabled: false },
]}
onItemClick={regionChange} // Run regionChange on dropdown change
>
{currentUserRegion}
</ButtonDropdown>
// Return our HTML
return (
<>
<header id="navbar">
{regionButtonDropdown}
</header>
</>
);
};
export default Navbar;
reducers.js
import { combineReducers } from "redux"
import regionReducer from "./regionReducer"
const reducers = combineReducers({
region: regionReducer
})
export default reducers;
regionReducer.js
import * as ACTIONS from '../actionsTypes'
const initialState = '';
const reducer = (state = initialState, action) => {
switch (action.type) {
case ACTIONS.CHANGE_REGION:
return action.payload;
default:
return state;
}
}
export default reducer;
store.js
import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import logger from 'redux-logger';
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
import rootReducer from './reducers/reducers'
const persistConfig = {
key: 'root',
storage
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
const middleware = applyMiddleware(thunk, logger);
const store = createStore(
persistedReducer,
middleware
);
const persistor = persistStore(store);
export { store, persistor };
Your useMemo only runs on page load. Change it to
useEffect(() => {
if (region && region !== '') {
setRegionState(region)
setPageData(fakeData);
} else {
setPageData([{}]);
}
}, [region]);
So that it runs whenever region changes. See Conditionally firing an effect from the React docs.
Specifically note,
If you want to run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([]) as a second argument. This tells React that your effect doesn’t depend on any values from props or state, so it never needs to re-run.
I want to write test cases for HeaderBar.jsx in the file HeaderBar.spec.jsx.
First I want to write test case for checking my history.push() is executing call then I want to check for history.push() executing with mockURL.
history.push() is found in onLogout()
here is HeaderBar.jsx
import React from 'react';
import { PropTypes } from 'prop-types';
import { bindActionCreators } from 'redux';
import { makeStyles } from '#material-ui/core/styles';
import AppBar from '#material-ui/core/AppBar';
import Toolbar from '#material-ui/core/Toolbar';
import Typography from '#material-ui/core/Typography';
import Button from '#material-ui/core/Button';
import IconButton from '#material-ui/core/IconButton';
import MenuIcon from '#material-ui/icons/Menu';
import { connect } from 'react-redux';
import { withTranslation } from 'react-i18next';
import { ROUTE_CONSTANTS, I18N_CONSTANTS } from '../../../../constants';
import { commonAction, sessionAction } from '../../../../redux/actions';
const useStyles = makeStyles(theme => ({
root: {
flexGrow: 1
},
menuButton: {
marginRight: theme.spacing(2)
},
title: {
flexGrow: 1
}
}));
const HeaderBar = ({ history, t, actions }) => {
const classes = useStyles();
const onLogout = () => {
history.push(ROUTE_CONSTANTS.MEMBER.LOGOUT);
actions.logout();
};
// const onLogout = () => { throw new Error('Failed to run') };
return (
<div className={classes.root}>
<AppBar position="static" color="default">
<Toolbar>
<IconButton edge="start" className={classes.menuButton} color="inherit" aria-label="menu">
<MenuIcon />
</IconButton>
<Typography variant="h6" className={classes.title}>
{t('headerBar.title')}
</Typography>
<Button color="inherit" onClick={onLogout}>
{t('headerBar.logout')}
</Button>
</Toolbar>
</AppBar>
</div>
);
};
HeaderBar.defaultProps = {
t: () => {},
history: {
push: () => {}
}
};
const mapStateToProps = state => {
return {
isLoading: state.common.loadingIndicator.isLoading
};
};
const mapDispatchToProps = dispatch => {
return {
actions: bindActionCreators({ ...commonAction, ...sessionAction }, dispatch)
};
};
HeaderBar.propTypes = {
actions: PropTypes.shape({
logout: PropTypes.func.isRequired
}).isRequired,
t: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func
})
};
const component = connect(mapStateToProps, mapDispatchToProps)(HeaderBar);
export default withTranslation(I18N_CONSTANTS.NAMESPACE.APP)(component);
HeaderBar.spec.jsx
import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from '../../../../redux/store/configureStore';
import HeaderBar from './HeaderBar';
const setup = (props = {}) => {
const store = configureStore();
window.location.replace = jest.fn();
const defaultProps = {
isLoading: false,
history: {
replace: jest.fn(x => x)
},
...props
};
return mount(
<Provider store={store}>
<HeaderBar {...defaultProps} />
</Provider>
);
};
describe('HeaderBar component', () => {
it('should call translation 2 times', () => {
const props = { t: jest.fn() };
const wrapper = setup(props);
const { t, isLoading } = wrapper.find('HeaderBar').props();
expect(t).toHaveBeenCalledTimes(2);
expect(isLoading).toBeFalsy();
});
it('should call history on onClick event', () => {
const props = { history: { replace: jest.fn(x => x) }, onLogout: jest.fn(x => x) };
const wrapper = setup(props);
// console.log(wrapper.find('button').debug());
wrapper.find('button').simulate('click');
expect(props.history.replace).toHaveBeenCalledTimes(1);
});
it('should call history with mock URL', () => {
const props = { history: { replace: jest.fn(x => x) } };
const wrapper = setup(props);
const mockURL = '/';
wrapper
.find('button')
.at(0)
.simulate('click');
expect(props.history.replace).toHaveBeenCalledWith(mockURL);
});
});
should call history on onClick event and should call history with mock URL is not working for me.
please help me out on this.
You mock history.replace, but in your component you use history.push.
You mock your functions’ implementations as (x => x), but these functions actually don’t receive any arguments.
Is your logout button really the only in this wrapper’s DOM? Maybe add some id?
I have a connected top level component :
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import store from './store/store';
import { Provider } from 'react-redux';
import LifePlanner from './lifePlanner';
import { getContainer, setContainer } from './store/plannerSlice'
class App extends React.Component {
componentDidMount() {
fetch("/lp/top-layer", { mode: 'cors' }).then((response) => {
if (response.status !== 200) {
console.log('Looks like there was a problem. Status Code: ' +
response.status);
return;
}
response.json().then((data) => {
store.dispatch(setContainer({ name: "Root Container", children: data.data.containers }));
this.setState({ container: getContainer(store.getState()) })
});
}
).catch(function (err) {
console.log('Fetch Error :-S', err);
});
}
render() {
let c = this.state.container;
if (c.name) {
return <LifePlanner container={c} />
}
return <h1>Loading</h1>;
}
}
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>
,
document.getElementById('root')
);
LifePLanner then calls an ExpandedCard component, psasing the container down and then again to a ImageCard component where I put an onClick, which changes the openContainer item in the store
import React from 'react';
import { makeStyles } from '#material-ui/core/styles';
import Card from '#material-ui/core/Card';
import CardActionArea from '#material-ui/core/CardActionArea';
import CardActions from '#material-ui/core/CardActions';
import CardContent from '#material-ui/core/CardContent';
import CardMedia from '#material-ui/core/CardMedia';
import Button from '#material-ui/core/Button';
import Typography from '#material-ui/core/Typography';
import { setContainer } from '../store/plannerSlice'
import store from '../store/store';
const useStyles = makeStyles({
media: { height: 250 },
});
export default function SectionCard({ id, name, image, description, children }) {
const classes = useStyles();
const handleClick = () => {
fetch(`/lp?${id}`, { mode: 'cors' }).then((response) => {
if (response.status !== 200) {
console.log('Looks like there was a problem. Status Code: ' +
response.status);
return;
}
response.json().then((data) => {
store.dispatch(setContainer({ name , children: data.data.containers }));
});
}
).catch(function (err) {
console.log('Fetch Error :-S', err);
});
}
return (
<Card className={classes.root} onClick={()=>handleClick()}>
<CardActionArea>
{image &&
<CardMedia
className={classes.media}
image={image}
title="Contemplative Reptile"
/>}
<CardContent>
<Typography gutterBottom variant="h5" component="h2">
{name}
</Typography>
<Typography variant="body2" color="textSecondary" component="p">
{description}
</Typography>
</CardContent>
</CardActionArea>
{(children && children.length > 0) &&
<CardActions>
<Button size="small" color="primary">
Expand (Contains {children.length} items)
</Button>
</CardActions>
}
</Card>
);
}
Heres my reducer too:
import { createSlice } from '#reduxjs/toolkit';
export const slice = createSlice({
name: 'containers',
initialState: {
openContainer: {}
},
reducers: {
setContainer: (state, action) => {
let payload = action.payload;
state.openContainer = {...payload}
},
},
});
export const getContainer = state => {
return state.containers.openContainer
};
export const { setContainer } = slice.actions;
export default slice.reducer;
None of the components get their componentWilLReceiveProps triggered or get notified of the change, but the store does get the change from the click
Yes, there's several issues here. The primary problems are:
You're interacting with the store directly in our components. Instead, you should be using the React-Redux APIs like connect and useSelector/useDispatch to read data from the store and dispatch actions in your components. That way they'll update properly when the store state gets updated.
You shouldn't be copying data from props to state just to render with it. Copying data from props to state is almost always the wrong approach.
A few other notes:
Conceptually, think of actions as "events that happened", not "setters". Instead of calling that reducer/action setContainer, call it something like containerDataLoaded.
You don't need to spread {...payload} in that reducer, since the object was constructed when you dispatched the action. You can simplify that line to state.openContainer = action.payload.
You might want to consider using async/await syntax instead of promise chaining.
I am new to working with sagas, I can’t solve the problem of "Actions must be plain objects. Use custom middleware for async actions."
I enclose all the necessary code. Already broke his head, solving the issue.
I hope for your help.
I looked at the documentation of the sagas, but did not find anything about this error.
I also watched the react boilerplate, where there are already sagas, but I would like to do this on CRA
action
import { AXIOS } from "../api";
import { takeLatest, put, call } from "redux-saga/effects";
export const GET_GENRES_PENDING = "GENRES::GET_GENRES_PENDING";
export const GET_GENRES_FULFILLED = "GENRES::GET_GENRES_FULFILLED";
export const GET_GENRES_REJECTED = "GENRES::GET_GENRES_REJECTED";
export const getGenresPending = () => ({
type: GET_GENRES_PENDING
});
export const getGenresFulfilled = data => ({
type: GET_GENRES_FULFILLED,
payload: data
});
export const getGenresRejected = error => ({
type: GET_GENRES_REJECTED,
payload: error
});
export function* getGenresAction() {
try {
yield put(getGenresPending());
const data = yield call(() => {
return AXIOS.get(
"/movie/list?api_key=5fcdb863130c33d2cb8f1612b76cbd30&language=ru-RU"
).then(response => {
console.log(response);
});
});
yield put(getGenresFulfilled(data));
} catch (error) {
yield put(getGenresRejected(error));
}
}
export default function* watchFetchGenres() {
yield takeLatest("FETCHED_GENRES", getGenresAction);
}
store
import { applyMiddleware, compose, createStore } from "redux";
import createSagaMiddleware from "redux-saga";
import rootReducer from "./reducers";
import watchFetchGenres from "./actions/getGenresAction";
const sagaMiddleware = createSagaMiddleware();
export function configureStore(initialState) {
const middleware = [sagaMiddleware];
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
rootReducer,
initialState,
composeEnhancers(applyMiddleware(...middleware))
);
sagaMiddleware.run(watchFetchGenres);
return store;
}
index.js
import React from "react";
import ReactDOM from "react-dom";
import { Provider } from "react-redux";
import App from "./containers/App";
import * as serviceWorker from "./serviceWorker";
import { configureStore } from "./core/configureStore.js";
const store = configureStore({});
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
serviceWorker.unregister();
App.js
import React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";
import MoviesContainer from "./MoviesContainer/MoviesContainer";
import FilterContainer from "./FilterContainer/FilterContainer";
import { Container, GlobalStyle } from "./style.js";
export default function App() {
return (
<Container className="app">
<GlobalStyle />
<Router>
<Route exact path="/" component={FilterContainer} />
<Route path="/movies" component={MoviesContainer} />
</Router>
</Container>
);
}
Container
import React, { useState, useEffect } from "react";
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import watchFetchGenres from "../../core/actions/getGenresAction";
import Card from "../../components/Card/Card";
import Button from "../../components/Button/Button";
import TextInput from "../../components/TextInput/TextInput";
import { TitleH1, TitleH2, TitleCard } from "../../components/Title/Title";
import { Container, SecondaryContainer } from "../style.js";
class FilterContainer extends React.Component {
// const dispatch = useDispatch();
// useEffect(() => {
// getGenresAction();
// // fetch('https://api.themoviedb.org/3/genre/movie/list?api_key=5fcdb863130c33d2cb8f1612b76cbd30&language=en-US')
// });
componentDidMount() {
this.props.watchFetchGenres();
}
render() {
return (
<Container>
<TitleH1 title="Фильтры" />
<SecondaryContainer>
<TextInput placeholder="Введите название фильма" />
</SecondaryContainer>
<SecondaryContainer filters>
<Card>
<TitleCard title="Фильтр по жанру" />
</Card>
<Card>
<TitleCard title="Фильтр по рейтингу" />
</Card>
<Card>
<TitleCard title="Фильтр по году" />
</Card>
</SecondaryContainer>
<SecondaryContainer>
<Button primary value="Применить фильтры" placeholder="lala" />
</SecondaryContainer>
</Container>
);
}
}
const mapStateToProps = state => ({
genres: state.genres
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ watchFetchGenres }, dispatch);
export default connect(
mapStateToProps,
mapDispatchToProps
)(FilterContainer);
bindActionCreators({ watchFetchGenres }, dispatch);
watchFetchGenres isn't an action creator, so this isn't correct. An action creator is function which returns an action. You have 3 examples of them in your code:
export const getGenresPending = () => ({
type: GET_GENRES_PENDING
});
export const getGenresFulfilled = data => ({
type: GET_GENRES_FULFILLED,
payload: data
});
export const getGenresRejected = error => ({
type: GET_GENRES_REJECTED,
payload: error
});
Those are the types of things you should be binding instead.
Your saga is listening for actions of type "FETCHED_GENRES", so the 3 existing action creators won't work for that. You may need to create another action creator, as in:
export const fetchGenres = () => ({
type: 'FETCHED_GENRES',
});
Then in your mapDispatchToProps, you'll make use of this action creator:
const mapDispatchToProps = dispatch =>
bindActionCreators({ fetchGenres }, dispatch);
And update where you call it:
componentDidMount() {
this.props.fetchGenres();
}
I think I found another way to test a component using the useContext hook. I have seen a few tutorials that test if a value can be successfully passed down to a child component from a parent Context Provider but did not find tutorials on the child updating the context value.
My solution is to render the root parent component along with the provider, because the state is ultimately changed in the root parent component and then passed to the provider which then passes it to all child components. Right?
The tests seem to pass when they should and not pass when they shouldn't.
Can someone explain why this is or isn't a good way to test the useContext hook?
The root parent component:
...
const App = () => {
const [state, setState] = useState("Some Text")
const changeText = () => {
setState("Some Other Text")
}
...
<h1> Basic Hook useContext</h1>
<Context.Provider value={{changeTextProp: changeText,
stateProp: state
}} >
<TestHookContext />
</Context.Provider>
)}
The context object:
import React from 'react';
const Context = React.createContext()
export default Context
The child component:
import React, { useContext } from 'react';
import Context from '../store/context';
const TestHookContext = () => {
const context = useContext(Context)
return (
<div>
<button onClick={context.changeTextProp}>
Change Text
</button>
<p>{context.stateProp}</p>
</div>
)
}
And the tests:
import React from 'react';
import ReactDOM from 'react-dom';
import TestHookContext from '../test_hook_context.js';
import {render, fireEvent, cleanup} from '#testing-library/react';
import App from '../../../App'
import Context from '../../store/context';
afterEach(cleanup)
it('Context is updated by child component', () => {
const { container, getByText } = render(<App>
<Context.Provider>
<TestHookContext />
</Context.Provider>
</App>);
console.log(container)
expect(getByText(/Some/i).textContent).toBe("Some Text")
fireEvent.click(getByText("Change Text"))
expect(getByText(/Some/i).textContent).toBe("Some Other Text")
})
The problem with the approach that you mention is coupling.
Your Context to be tested, depends on <TestHookContext/> and <App/>
As Kent C. Dodds, the author of react-testing-library, has a full article on "Test Isolation with React" if you want to give it a read.
TLDR: Demo repo here
How to test Context
Export a <ContextProvider> component that holds the state and returns <MyContext.Provider value={{yourWhole: "State"}}>{children}<MyContext.Provider/> This is the component that we are going to test for the provider.
On the components that consume that Context, create a MockContextProvider to replace the original one. You want to test the component in isolation.
You can test the whole app workflow to by testing the root component.
Testing Auth Provider
Let's say we have a component that provides Auth using context the following way:
import React, { createContext, useState } from "react";
export const AuthContext = createContext();
const AuthProvider = ({ children }) => {
const [isLoggedin, setIsLoggedin] = useState(false);
const [user, setUser] = useState(null);
const login = (user) => {
setIsLoggedin(true);
setUser(user);
};
const logout = () => {
setIsLoggedin(false);
setUser(null);
};
return (
<AuthContext.Provider value={{ logout, login, isLoggedin, user }}>
{children}
</AuthContext.Provider>
);
};
export default AuthProvider;
The test file would look like:
import { fireEvent, render, screen } from "#testing-library/react";
import AuthProvider, { AuthContext } from "./AuthProvider";
import { useContext } from "react";
const CustomTest = () => {
const { logout, login, isLoggedin, user } = useContext(AuthContext);
return (
<div>
<div data-testid="isLoggedin">{JSON.stringify(isLoggedin)}</div>
<div data-testid="user">{JSON.stringify(user)}</div>
<button onClick={() => login("demo")} aria-label="login">
Login
</button>
<button onClick={logout} aria-label="logout">
LogOut
</button>
</div>
);
};
test("Should render initial values", () => {
render(
<AuthProvider>
<CustomTest />
</AuthProvider>
);
expect(screen.getByTestId("isLoggedin")).toHaveTextContent("false");
expect(screen.getByTestId("user")).toHaveTextContent("null");
});
test("Should Login", () => {
render(
<AuthProvider>
<CustomTest />
</AuthProvider>
);
const loginButton = screen.getByRole("button", { name: "login" });
fireEvent.click(loginButton);
expect(screen.getByTestId("isLoggedin")).toHaveTextContent("true");
expect(screen.getByTestId("user")).toHaveTextContent("demo");
});
test("Should Logout", () => {
render(
<AuthProvider>
<CustomTest />
</AuthProvider>
);
const loginButton = screen.getByRole("button", { name: "logout" });
fireEvent.click(loginButton);
expect(screen.getByTestId("isLoggedin")).toHaveTextContent("false");
expect(screen.getByTestId("user")).toHaveTextContent("null");
});
Testing Component that consumes Context
import React, { useContext } from "react";
import { AuthContext } from "../context/AuthProvider";
const Welcome = () => {
const { logout, login, isLoggedin, user } = useContext(AuthContext);
return (
<div>
{user && <div>Hello {user}</div>}
{!user && <div>Hello Anonymous Goose</div>}
{!isLoggedin && (
<button aria-label="login" onClick={() => login("Jony")}>
Log In
</button>
)}
{isLoggedin && (
<button aria-label="logout" onClick={() => logout()}>
Log out
</button>
)}
</div>
);
};
export default Welcome;
We will mock the AuthContext value by providing one of our own:
import React, { useContext } from "react";
import { render, screen } from "#testing-library/react";
import "#testing-library/jest-dom";
import Welcome from "./welcome";
import userEvent from "#testing-library/user-event";
import { AuthContext } from "../context/AuthProvider";
// A custom provider, not the AuthProvider, to test it in isolation.
// This customRender will be a fake AuthProvider, one that I can controll to abstract of AuthProvider issues.
const customRender = (ui, { providerProps, ...renderOptions }) => {
return render(
<AuthContext.Provider value={providerProps}>{ui}</AuthContext.Provider>,
renderOptions
);
};
describe("Testing Context Consumer", () => {
let providerProps;
beforeEach(
() =>
(providerProps = {
user: "C3PO",
login: jest.fn(function (user) {
providerProps.user = user;
providerProps.isLoggedin = true;
}),
logout: jest.fn(function () {
providerProps.user = null;
providerProps.isLoggedin = false;
}),
isLoggedin: true,
})
);
test("Should render the user Name when user is signed in", () => {
customRender(<Welcome />, { providerProps });
expect(screen.getByText(/Hello/i)).toHaveTextContent("Hello C3PO");
});
test("Should render Hello Anonymous Goose when is NOT signed in", () => {
providerProps.isLoggedin = false;
providerProps.user = null;
customRender(<Welcome />, { providerProps });
expect(screen.getByText(/Hello/i)).toHaveTextContent(
"Hello Anonymous Goose"
);
});
test("Should render Logout button when user is signed in", () => {
customRender(<Welcome />, { providerProps });
expect(screen.getByRole("button", { name: "logout" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "login" })).toBeNull();
});
test("Should render Login button when user is NOT signed in", () => {
providerProps.isLoggedin = false;
providerProps.user = null;
customRender(<Welcome />, { providerProps });
expect(screen.getByRole("button", { name: "login" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "logout" })).toBeNull();
});
test("Should Logout when user is signed in", () => {
const { rerender } = customRender(<Welcome />, { providerProps });
const logout = screen.getByRole("button", { name: "logout" });
expect(logout).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "login" })).toBeNull();
userEvent.click(logout);
expect(providerProps.logout).toHaveBeenCalledTimes(1);
//Technically, re renders are responsability of the parent component, but since we are here...
rerender(
<AuthContext.Provider value={providerProps}>
<Welcome />
</AuthContext.Provider>
);
expect(screen.getByText(/Hello/i)).toHaveTextContent(
"Hello Anonymous Goose"
);
expect(screen.getByRole("button", { name: "login" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "logout" })).toBeNull();
});
test("Should Login when user is NOT signed in", () => {
providerProps.isLoggedin = false;
providerProps.user = null;
const { rerender } = customRender(<Welcome />, { providerProps });
const login = screen.getByRole("button", { name: "login" });
expect(login).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "logout" })).toBeNull();
userEvent.click(login);
expect(providerProps.login).toHaveBeenCalledTimes(1);
//Technically, re renders are responsability of the parent component, but since we are here...
rerender(
<AuthContext.Provider value={providerProps}>
<Welcome />
</AuthContext.Provider>
);
expect(screen.getByText(/Hello/i)).toHaveTextContent("Hello Jony");
expect(screen.getByRole("button", { name: "logout" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "login" })).toBeNull();
});
});
Your example/code is dead on. (Not sure you need to mount the wrapping <App /> - you should just wrap in the Context Provider directly).
To your question:
The tests seem to pass when they should and not pass when they shouldnt. Can someone explain why this is or isnt a good way to test the useContext() hook.
It is a good way to test when using useContext() because it looks like you have abstracted out your context so that your child (consuming) component and its test both use the same context. I don't see any reason why you would mock or emulate what the context provider is doing when (as you do in your example) you use the same context provider.
The React Testing Library docs point out that:
The more your tests resemble the way your software is used, the more confidence they can give you.
Therefore, setting up your tests the same way you set up your components achieves that goal. If you have multiple tests in one app that do need to be wrapped in the same context, this blog post has a neat solution for reusing that logic.