I have code where user can click on the ingredient and then I store his selected ingredient inside context and redirect him to a details page:
const [, setSelectedItem] = useContext(itemContext);
const [, setSelectedMealNumber] = useContext(selectedMealNumberContext);
const editIngredient = (event: React.MouseEvent<HTMLElement>) => {
setSelectedItem(ingredient);
setSelectedMealNumber(mealNumber);
router.push(`/i/${ingredient?.uid}`);
};
However selectedItem is initialized as null in my context provider:
// Provider component
function ApiStore({ children }) {
const [selectedItem, setSelectedItem] = useState(null);
So when testing details page:
it("renders the selected item productName", () => {
const queryClient = new QueryClient();
render(
<ApiStore>
<QueryClientProvider client={queryClient}>
<IngredientExpanded />
</QueryClientProvider>
</ApiStore>
);
const productName = screen.getByText(FakeItem1.productName);
expect(productName).toBeInTheDocument();
});
Test fails, because selectedItem is null.
At the moment I manually initialize the value in my context provider like this and my test succeeds:
const [selectedItem, setSelectedItem] = useState(FakeItem1);
But since I need this value only for my tests and not the actual app, I have to remove it and set it back to null on my production app.
I don't need to mock everything since, everything else works. How can I simply inject the selectedItem during my test?
The easiest way is to add value props for ApiStore component. Then you can provide the initial value for the context provider.
index.ts:
import React, { useContext, useState } from 'react';
const ItemContext = React.createContext({
selectedItem: null,
setSelectedItem: (state) => {},
});
export const ApiStore = ({ children, value }) => {
const [selectedItem, setSelectedItem] = useState(value);
return <ItemContext.Provider value={{ selectedItem, setSelectedItem }}>{children}</ItemContext.Provider>;
};
export const IngredientExpanded = () => {
const { selectedItem } = useContext(ItemContext);
return <div>{selectedItem}</div>;
};
index.test.ts:
import { render, screen } from '#testing-library/react';
import '#testing-library/jest-dom/extend-expect';
import React from 'react';
import { ApiStore, IngredientExpanded } from '.';
describe('72047880', () => {
test('should find the selected item', () => {
render(
<ApiStore value={'product name'}>
<IngredientExpanded />
</ApiStore>
);
const productName = screen.getByText('product name');
expect(productName).toBeInTheDocument();
});
test('should not find the selected item', () => {
render(
<ApiStore value={null}>
<IngredientExpanded />
</ApiStore>
);
const productName = screen.queryByText('product name');
expect(productName).not.toBeInTheDocument();
});
});
test result:
PASS stackoverflow/72047880/index.test.tsx (11.239 s)
72047880
✓ should find the selected item (23 ms)
✓ should not find the selected item (3 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 12.855 s
Related
I am trying to open a modal by updating the state of the component. The component is wrapped in a Context Provider.
Although the button seems to be clicking successfully, the Modal will not open
Here is the code with the container which contains the "Open Modal Button"
import { type FC, useRef } from 'react'
import infomation from '#/assets/icons/infomation.svg'
import { useModal } from '#/providers/ModalProvider'
import styles from './Instructions.module.scss'
const Instructions: FC = () => {
const card = useRef<HTMLDivElement>(null)
const { openDemoModal } = useModal()
const onOpenModalClick = () => {
openDemoModal()
console.log(openDemoModal)
}
return (
<section ref={card} className={`card ${styles.card}`}>
<div className={styles.background} />
<div>OPEN THE MODAL DOWN BELOW</div>
<button variant="outlined" fullWidth onClick={onOpenModalClick}>
Open Modal
</button>
</section>
)
}
export default Instructions
Here is the file which contains the Context for the Modal, I have tried setting up the context in INITIAL_STATE and tried updating it using the "onOpenModalClick" function - but it doesn't seem to be able to update the ShowModal.current value below.
import { type FC, type PropsWithChildren, createContext, useContext, useRef } from 'react'
import Modal from '#/components/modal/Modal'
type ContextValue = {
showModal: boolean
openDemoModal: () => void
}
const INITIAL_STATE: ContextValue = {
showModal: false,
openDemoModal: () => {},
}
export const ModalContext = createContext(INITIAL_STATE)
export const ModalProvider: FC<PropsWithChildren> = ({ children }) => {
const showModal = useRef(INITIAL_STATE.showModal)
const openDemoModal = () => {
showModal.current = true
}
console.log(showModal.current)
return (
<ModalContext.Provider value={{ showModal: showModal.current, openDemoModal }}>
{children}
<Modal show={showModal.current} setShow={(shouldShow: boolean) => (showModal.current = shouldShow)} />
</ModalContext.Provider>
)
}
export function useModal() {
const context = useContext(ModalContext)
if (!context) {
throw new Error('useModal must be used within a ModalProvider')
}
return context
}
Is there any way to update the onOpenModalClick button to make it change the value of showModal.current in the Provider file?
Sorry if the post is unclear, this is my first Stack Overflow post. Let me know if I need to post anymore of the components.
I tried to add a button to the component which updates the Context, however the state failed to update
The useRef does not trigger a re-render when the value changes. Instead you can use a useState hook. Which would look something like this.
type ContextValue = {
showModal: boolean;
openDemoModal: () => void;
};
const INITIAL_STATE: ContextValue = {
showModal: false,
openDemoModal: () => console.warn('No ModalProvider'),
};
export const ModalContext = createContext(INITIAL_STATE);
export const ModalProvider: FC<PropsWithChildren> = ({ children }) => {
const [showModal, setShowModal] = useState(false);
const openDemoModal = () => {
setShowModal(true)
};
console.log(showModal);
return (
<ModalContext.Provider
value={{ showModal, openDemoModal }}
>
{children}
<Modal
show={showModal}
setShow={setShowModal}
/>
</ModalContext.Provider>
);
};
This is a simple question but I couldn't reach the final result after a lot of attempts. The problem is that I want to pass an object in context and use it in another file. And then do an iteration and create a specific element for each value.
App.jsx
const [activities, setActivity] = useState([
{
key: Math.random() * Math.random(),
name: 'Hello',
}
]);
const inputValue = useRef(null);
const addActivity = () => {
const activity = {
key: Math.random() * Math.random(),
name: inputValue.current.value,
};
setActivity(activities.concat(activity));
};
const value = {
// I want to pass this parameter - only activities has problem (Activity.jsx <h1>)
// I can't achieve activities.name in Activity.jsx
activities: [...activities],
functions: {
addActivity: addActivity
},
ref: {
inputValue: inputValue
}
};
<Context.Provider
value={value}
>
Context.js
export const Context = createContext();
Activity.jsx
const { activities, functions, ref } = useContext(Context);
return (
<section className="activity-container">
<input type="text" ref={ref.inputValue} />
<button onClick={functions.addActivity}>add!</button>
{
activities.map(activity => (
<h1>activity.name</h1>
))
}
</section>
);
I believe this is what you want:
// Sharing data through context
Context file:
// Context.js
import React, { useState, useRef, createContext } from "react";
export const DataContext = createContext();
const getRandom = () => Math.random() * Math.random();
const defaultValue = {
key: getRandom(),
name: "Hello"
};
const ContextProvider = ({ children }) => {
const [activities, setActivity] = useState([defaultValue]);
const inputValue = useRef(null);
const addActivity = () => {
const activity = {
key: getRandom(),
name: inputValue.current.value
};
setActivity([...activities, activity]);
};
const value = {
activities: [...activities],
functions: { addActivity },
ref: { inputValue }
};
return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
};
export default ContextProvider;
Hook to read from context:
// useDataContext
import { useContext } from "react";
import { DataContext } from "./Context";
const useDataContext = () => {
const contextValue = useContext(DataContext);
return contextValue;
};
export default useDataContext;
Child Element where you want to receive the value from context:
// Child.js
import React from "react";
import useDataContext from "./useDataContext";
const Child = () => {
const data = useDataContext();
return (
<>
{data.activities.map((val, idx) => (
<div key={idx}>Name is {val.name}</div>
))}
</>
);
};
export default Child;
And the App container:
// App.js
import Child from "./Child";
import ContextProvider from "./Context";
export default function App() {
return (
<div className="App">
<ContextProvider>
<Child />
</ContextProvider>
</div>
);
}
I've created a sandbox for you to test.
You should make sure that the Activity.jsx component is wrapped with context provider, to get the proper value from the context.
I tried in this codesandbox, and it's working properly. You can refer to this and check what you are missing.
I have been struggling to test a React Context update for a while. I can predefine the value of the Context.Provider however, the solution is not ideal because an update to the context which is supposed to happen within component utilising the context is not actually happening.
When I test this manually the text 'Account name: abc' changes to 'Account name: New account name' but not in the test. The context value remains the same.
The reason I predefine the value is because the component relies on a fetch response from a parent and I am unit testing <ImportAccounts /> only.
In a test I am rendering a component with predefined value
test('accountName value is updated on click', () => {
const { getByText, getByLabelText, getByRole } = render(
<ImportAccountsContext.Provider value={{ accountName: { value: 'abc', setValue: jest.fn() }}}>
<ImportAccounts />
</ImportAccountsContext.Provider>,
);
expect(getByText('Account name: abc')).toBeInTheDocument();
const input = getByLabelText('Account name');
fireEvent.change(input, { target: { value: 'New account name' } });
fireEvent.click(getByRole('button', { name: 'Update' }));
expect(getByText('Account name: New account name')).toBeInTheDocument();
});
Here's my context
import React, { createContext, useContext, useState, useCallback, Dispatch, SetStateAction } from 'react';
export interface StateVariable<T> {
value: T;
setValue: Dispatch<SetStateAction<T>>;
}
export interface ImportAccountsState {
accountName: StateVariable<string>;
}
export const ImportAccountsContext = createContext<ImportAccountsState>(
{} as ImportAccountsState,
);
export const ImportAccountsProvider = ({ children }: { children: React.ReactNode }) => {
const [accountName, setAccountName] = useState('');
const initialState: ImportAccountsState = {
accountName: {
value: accountName,
setValue: setAccountName,
},
};
return (
<ImportAccountsContext.Provider value={initialState}>
{children}
</ImportAccountsContext.Provider>
);
};
export const useImportAccountsContext = () => {
return useContext<ImportAccountsState>(ImportAccountsContext);
};
Import Accounts is as simple as
export const ImportAccounts = () => {
const { accountName } = useImportAccountsContext();
const [newAccountName, setNewAccountName] = useState(accountName.value);
const handleAccountNameChange = () => {
accountName.setValue(newAccountName);
};
return (
<>
<h1>Account name: {accountName.value}</h1>
<label htmlFor="accountName">Account name</label>
<input
value={newAccountName}
onChange={e => setNewAccountName(e.target.value)}
id="accountName"
/>
<button
type="button"
onClick={handleAccountNameChange}>
Update
</button>
</>
);
}
How can I test that accountName has actually updated?
If we don't need the default value for the ImportAccounts provider then we make the test pass easily. ImportAccountsProvider manages the state of the accountName within itself. In that provider, we are passing the accountName state of type ImportAccountsState to all our children through the context provider.
Now coming to your problem,
const { getByText, getByLabelText, getByRole } = render(
<ImportAccountsContext.Provider value={{ accountName: { value: 'abc', setValue: jest.fn() }}}>
<ImportAccounts />
</ImportAccountsContext.Provider>,
);
Here, the value: 'abc' is not a state value, it's simply a string constant 'abc' which will never be going to change. This is something that we should note. We must pass the state value to the context provider if we want to share the value with the children which is not going to be constant in the entire react lifecycle.
import { render, screen } from '#testing-library/react';
import userEvent from '#testing-library/user-event';
test('should update the context with existing provider', () => {
render(
<ImportAccountsProvider>
<ImportAccounts />
</ImportAccountsProvider>
);
// some constant hoisted to make it clean code
const accountInput = screen.getByRole('textbox', { name: /account name/i });
const accountInputValue = 'subrato patnaik';
expect(accountInput).toHaveAttribute('value', '');
// do some changes
userEvent.type(accountInput, accountInputValue);
//validate "changes"
expect(screen.getByDisplayValue(accountInputValue)).toBeTruthy();
expect(accountInput).toHaveAttribute('value', accountInputValue);
// update context
userEvent.click(screen.getByRole('button', { name: /update/i }));
// validate update
expect(screen.getByRole('heading')).toHaveTextContent(/subrato/i);
screen.debug();
});
Inside the ImportAccountsProvider we can do the fetch call and set the accountName state to the response of the fetch call.
export const ImportAccountsProvider = ({ children }: { children: React.ReactNode }) => {
const [accountName, setAccountName] = useState('');
useEffect(() => {
// do the fetch call here and update the accountName state accordingly
});
const initialState: ImportAccountsState = {
accountName: {
value: accountName,
setValue: setAccountName,
},
};
return (
<ImportAccountsContext.Provider value={initialState}>
{children}
</ImportAccountsContext.Provider>
);
};
export const useImportAccountsContext = () => {
return useContext<ImportAccountsState>(ImportAccountsContext);
};
I have question about react-testing-library with custom hooks
My tests seem to pass when I use context in custom hook, but when I update context value in hooks cleanup function and not pass.
So can someone explain why this is or isn't a good way to test the custom hook ?
The provider and hook code:
// component.tsx
import * as React from "react";
const CountContext = React.createContext({
count: 0,
setCount: (c: number) => {},
});
export const CountProvider = ({ children }) => {
const [count, setCount] = React.useState(0);
const value = { count, setCount };
return <CountContext.Provider value={value}>{children}</CountContext.Provider>;
};
export const useCount = () => {
const { count, setCount } = React.useContext(Context);
React.useEffect(() => {
return () => setCount(50);
}, []);
return { count, setCount };
};
The test code:
// component.spec.tsx
import * as React from "react";
import { act, render, screen } from "#testing-library/react";
import { CountProvider, useCount } from "./component";
describe("useCount", () => {
it("should save count when unmount and restore count", () => {
const Wrapper = ({ children }) => {
return <ContextStateProvider>{children}</ContextStateProvider>;
};
const Component = () => {
const { count, setCount } = useCount();
return (
<div>
<div data-testid="foo">{count}</div>
</div>
);
};
const { unmount, rerender, getByTestId, getByText } = render(
<Component />, { wrapper: Wrapper }
);
expect(getByTestId("foo").textContent).toBe("0");
unmount();
rerender(<Component />);
// I Expected: "50" but Received: "0". but I dont understand why
expect(getByTestId("foo").textContent).toBe("50");
});
});
When you call render, the rendered component tree is like this:
base element(document.body by default) -> container(createElement('div') by default) -> wrapper(CountProvider) -> Component
When you unmount the component instance, Wrapper will also be unmounted. See here.
When you rerender a new component instance, it just uses a new useCount hook and the default context value(you doesn' provide a context provider for rerender) in the useContext. So the count will always be 0. From the doc React.createContext:
The defaultValue argument is only used when a component does not have a matching Provider above it in the tree.
You should NOT unmount the CountProvider wrapper, you may want to just unmount the Component. So that the component will receive the latest context value after mutate it.
So, the test component should be designed like this:
component.tsx:
import React from 'react';
const CountContext = React.createContext({
count: 0,
setCount: (c: number) => {},
});
export const CountProvider = ({ children }) => {
const [count, setCount] = React.useState(0);
return <CountContext.Provider value={{ count, setCount }}>{children}</CountContext.Provider>;
};
export const useCount = () => {
const { count, setCount } = React.useContext(CountContext);
React.useEffect(() => {
return () => setCount(50);
}, []);
return { count, setCount };
};
component.test.tsx:
import React, { useState } from 'react';
import { fireEvent, render } from '#testing-library/react';
import { CountProvider, useCount } from './component';
describe('useCount', () => {
it('should save count when unmount and restore count', () => {
const Wrapper: React.ComponentType = ({ children }) => {
const [visible, setVisible] = useState(true);
return (
<CountProvider>
{visible && children}
<button data-testid="toggle" onClick={() => setVisible((pre) => !pre)}></button>
</CountProvider>
);
};
const Component = () => {
const { count } = useCount();
return <div data-testid="foo">{count}</div>;
};
const { getByTestId } = render(<Component />, { wrapper: Wrapper });
expect(getByTestId('foo').textContent).toBe('0');
fireEvent.click(getByTestId('toggle')); // unmount the Component
fireEvent.click(getByTestId('toggle')); // mount the Component again
expect(getByTestId('foo').textContent).toBe('50');
});
});
Test result:
PASS stackoverflow/67749630/component.test.tsx (8.46 s)
useCount
✓ should save count when unmount and restore count (54 ms)
---------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files | 100 | 100 | 80 | 100 |
component.tsx | 100 | 100 | 80 | 100 |
---------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 8.988 s, estimated 9 s
Also take a look at this example: Codesandbox
I am trying to learn how to bind functions in reactjs to events set in child. The one canvas (canvas2) has a mouse move event and the other canvas (canvas1) shall receive data from that event when there is any (=mouse moves).
But none of the functions are called and console.log doesn't show up.
Parent App.js
const [move, setMove] = useState();
const handleMove = (e) => {
console.log(e); //shows nothing
setMove(e.target);
}
return(
<>
<Canvas1 move={move} />
<Canvas2 handleMove={handleMove} />
</>
);
Canvas1:
useEffect(() => {
console.log(props.move); //shows nothing
}, [props.move]); //when props.move has new data, I wand this to trigger
Canvas2:
const [canvas, setCanvas] = useState();
useEffect(() => {
if(!canvas) {
setCanvas(initCanvas());
return;
}
canvas.on("mouse:move", props.handleMove); //bind mouse event to parent function
}, [canvas]);
return(
<canvas></canvas>
);
Canvas1
Could you try to change move and then check the message. (Anything has to be shown up in the console in this way)
useEffect(() => {
console.log(props.move); //shows nothing
}, [props.move]); //when props.move has new data, I wand this to trigger
Canvas2
Could you try this below? and then let me know what message comes up.
const [canvas, setCanvas] = useState();
useEffect(() => {
console.log(canvas);
if(!canvas) {
setCanvas(initCanvas()); // This set up a size of the canvas, doesn't this?
return;
}
// canvas.on("mouse:move", props.handleMove); //bind mouse event to parent function
canvas.addEventListener("mousemove", props.handleMove);
}, [canvas]);
return(
<canvas></canvas>
);
I'm not sure about this... If you get messages from the code, let me know. I'm trying to find out what problems are
=========================
-EDITED-
[App.js]
import React, { useState } from "react";
import Canvas1 from "./components/Canvas1";
import Canvas2 from "./components/Canvas2";
function App() {
const [move, setMove] = useState();
const handleMove = e => {
setMove(e.target);
};
return (
<>
<Canvas1 move={move} />
<Canvas2 handleMove={handleMove} />
</>
);
}
export default App;
[Canvas1]
import React, { useEffect } from "react";
const Canvas1 = props => {
useEffect(() => {
console.log(`props.move:${props.move}`);
}, [props.move]);
return <></>;
};
export default Canvas1;
[Canvas2]
import React, { useEffect, useState, useRef } from "react";
const initCanvas = () => {
const newCanvas = document.createElement("canvas");
newCanvas.setAttribute("width", "500px");
newCanvas.setAttribute("height", "500px");
return newCanvas;
};
const Canvas2 = props => {
const [canvas, setCanvas] = useState();
const canvasRef = useRef();
useEffect(() => {
if (!canvas) {
setCanvas(initCanvas());
return;
}
}, [canvas]);
useEffect(() => {
canvasRef.current.addEventListener("mousemove", props.handleMove); // It doens't work
}, [props.handleMove]);
return <canvas ref={canvasRef} />;
};
export default Canvas2;