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
Related
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
I'm trying to test this custom hook but I don't know how to send the useRef as a parameter
ElementRef is using useRef
import { MutableRefObject, useEffect, useState } from "react";
export default function useNearScreen(elementRef: MutableRefObject<HTMLDivElement>, margin = 80) {
const [show, setShow] = useState(false);
useEffect(() => {
const onChange = (entries: IntersectionObserverEntry[]) => {
const el: IntersectionObserverEntry = entries[0];
if (el.isIntersecting) {
setShow(true)
observer.disconnect();
}
}
const observer = new IntersectionObserver(onChange, {
rootMargin: `${margin}px`
})
observer.observe(elementRef.current as Element);
return () => observer.disconnect();
})
return show;
}
First of all, jest use jsdom as its test environment by default. jsdom doesn't support IntersectionObserver, see issue#2032. So we need to mock it and trigger the callback manually.
I will use #testing-library/react-hooks package to test react custom hook.
E.g.
useNearScreen.ts:
import { MutableRefObject, useEffect, useState } from 'react';
export default function useNearScreen(elementRef: MutableRefObject<HTMLDivElement>, margin = 80) {
const [show, setShow] = useState(false);
useEffect(() => {
const onChange = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
const el: IntersectionObserverEntry = entries[0];
if (el.isIntersecting) {
setShow(true);
observer.disconnect();
}
};
const observer = new IntersectionObserver(onChange, {
rootMargin: `${margin}px`,
});
observer.observe(elementRef.current as Element);
return () => observer.disconnect();
});
return show;
}
useNearScreen.test.ts:
import { renderHook } from '#testing-library/react-hooks';
import { MutableRefObject } from 'react';
import { useRef } from 'react';
import useNearScreen from './useNearScreen';
describe('useNearScreen', () => {
test('should pass', () => {
const mObserver = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
};
const mIntersectionObserver = jest.fn();
mIntersectionObserver.mockImplementation((callback, options) => {
callback([{ isIntersecting: true }], mObserver);
return mObserver;
});
window.IntersectionObserver = mIntersectionObserver;
const mHTMLDivElement = document.createElement('div');
const { result } = renderHook(() => {
const elementRef = useRef<HTMLDivElement>(mHTMLDivElement);
return useNearScreen(elementRef as MutableRefObject<HTMLDivElement>);
});
expect(result.current).toBe(true);
expect(mIntersectionObserver).toBeCalledWith(expect.any(Function), { rootMargin: '80px' });
expect(mObserver.observe).toBeCalledWith(mHTMLDivElement);
expect(mObserver.disconnect).toBeCalled();
});
});
Test result:
PASS stackoverflow/71118856/useNearScreen.test.ts (9.656 s)
useNearScreen
✓ should pass (16 ms)
------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------|---------|----------|---------|---------|-------------------
All files | 100 | 66.67 | 100 | 100 |
useNearScreen.ts | 100 | 66.67 | 100 | 100 | 9
------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 10.581 s
I'm getting fed up with trying to test hooks but I feel so close with this approach. Here me out.
I've got this test running and it gives me this error:
'TypeError: handleCount is not a function'
describe("<Content />", () => {
const setCount = jest.fn();
let activeTab = 'Year';
test("Ensure that handleCount is fired if activeTab is the type year", () => {
handleYearTab(setCount, activeTab);
});
});
So this makes sense but I'm not sure how I can mock the method that it is complaining about. this is my component that I'm trying to test:
/**
* Get new count from getTotalAttendances
* #param dates | New date picked by the user
* #param setCount | Hook function
* #param activeTab | Type of tab
*/
function handleCount(
dates: object,
setCount: Function,
activeTab?: string,
) {
const totalCount = new GetTotal(dates, activeTab);
setCount(totalCount.totalAttendances());
}
/**
* Handle count for the year tab.
* #param setCount | Hook function
* #param activeTab | Type of tab
*/
export function handleYearTab(
setCount: Function,
activeTab: string,
) {
if (activeTab === 'Year') {
handleCount(new Date(), setCount, activeTab);
}
}
const Content: FC<Props> = ({ activeTab }) => {
const [count, setCount] = useState<number>(0);
useEffect(() => {
handleYearTab(setCount, activeTab);
});
return (
<Container>
<TotalAttendences count={count} />
</Container>
);
}
export default Content;
I'm really curious how you would go about mocking the handleCount method.
Here is the unit test solution using jestjs and react-dom/test-utils:
index.tsx:
import React, { FC, useState, useEffect } from 'react';
import { GetTotal } from './getTotal';
interface Props {
activeTab: string;
}
function handleCount(dates: object, setCount: Function, activeTab?: string) {
const totalCount = new GetTotal(dates, activeTab);
setCount(totalCount.totalAttendances());
}
export function handleYearTab(setCount: Function, activeTab: string) {
if (activeTab === 'Year') {
handleCount(new Date(), setCount, activeTab);
}
}
const Content: FC<Props> = ({ activeTab }) => {
const [count, setCount] = useState<number>(0);
useEffect(() => {
handleYearTab(setCount, activeTab);
});
return <div>{count}</div>;
};
export default Content;
getTotal.ts:
export class GetTotal {
constructor(dates, activeTab) {}
public totalAttendances(): number {
return 1;
}
}
index.test.tsx:
import Content from './';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { act } from 'react-dom/test-utils';
import { GetTotal } from './getTotal';
describe('60638277', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
unmountComponentAtNode(container);
container.remove();
container = null;
});
it('should handle year tab', async () => {
const totalAttendancesSpy = jest.spyOn(GetTotal.prototype, 'totalAttendances').mockReturnValue(100);
const mProps = { activeTab: 'Year' };
await act(async () => {
render(<Content {...mProps}></Content>, container);
});
expect(container.querySelector('div').textContent).toBe('100');
expect(totalAttendancesSpy).toBeCalled();
totalAttendancesSpy.mockRestore();
});
it('should render initial count', async () => {
const mProps = { activeTab: '' };
await act(async () => {
render(<Content {...mProps}></Content>, container);
});
expect(container.querySelector('div').textContent).toBe('0');
});
});
unit test results with coverage report:
PASS stackoverflow/60638277/index.test.tsx (9.331s)
60638277
✓ should handle year tab (32ms)
✓ should render initial count (11ms)
-------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------|---------|----------|---------|---------|-------------------
All files | 95.24 | 100 | 85.71 | 94.12 |
getTotal.ts | 80 | 100 | 66.67 | 75 | 4
index.tsx | 100 | 100 | 100 | 100 |
-------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 10.691s
source code: https://github.com/mrdulin/react-apollo-graphql-starter-kit/tree/master/stackoverflow/60638277
I have this connected component that i am trying to test, i import two actions that dispatch a call to the store. The actual button click i am trying to test it should toggle between css classes.
When i simulate the click in my test it i get a error that one of my actions triggered by the click event is not a function.
TypeError: setLikedProducts is not a function
13 |
14 | const handleLike = () => {
> 15 | return like ? (setLike(false), removeLikedProduct(product)) : (setLike(true), setLikedProducts(product));
| ^
16 | }
17 |
18 | return (
My Component:
export function LikeProduct (props) {
const [like, setLike] = useState(false);
const { product, setLikedProducts, removeLikedProduct } = props;
const handleLike = () => {
return like ? (setLike(false), removeLikedProduct(product)) : (setLike(true), setLikedProducts(product));
}
return (
<div className="LikeProduct">
<Button
className={like ? "LikeProduct__like" : "LikeProduct__button"}
variant="link"
onClick={handleLike}>
<FaRegThumbsUp />
</Button>
</div>
);
}
const mapDispatchToProps = () => {
return {
setLikedProducts,
removeLikedProduct
}
}
export default connect(null, mapDispatchToProps())(LikeProduct);
my Test:
const props = {
info: {
product: "",
setLikedProducts: jest.fn(),
removeLikedProduct: jest.fn()
}
}
describe('Does LikeProduct Component Render', () => {
let wrapper = shallow(<LikeProduct {...props}/>);
it('LikeProduct render its css class', () => {
expect(wrapper.find('.LikeProduct').length).toBe(1);
});
it('Trigger the button on LikeProduct', () => {
console.log(wrapper.debug())
wrapper.find('Button').simulate('click');
});
Not sure why i am getting this error
your props are incorrectly defined, given your props contract
it should be
const props = {
product: "",
setLikedProducts: jest.fn(),
removeLikedProduct: jest.fn()
}
By the way, just in case you don't know, you can use useDispatch hook from react-redux in order to access dispatch function, instead of using connect
I am doing snapshot testing for one component which has ref on its div. The component looks like -
import React, { PureComponent } from 'react';
class SearchFlightBuilder extends PureComponent {
scrollRef = React.createRef();
state = {
loading: true,
error: false,
filteredList: [],
pageIndex: 0,
scrollCalled: false,
};
handleScroll = (event) => {
// make sure scroll should be called once
if ((this.scrollRef.current.scrollTop + this.scrollRef.current.clientHeight >= this.scrollRef.current.scrollHeight) && !this.state.scrollCalled) {
this.setState({
pageIndex: this.state.pageIndex + 1
});
this.setState({scrollCalled: true});
}
};
componentDidMount = () => {
this.scrollRef.current.addEventListener('scroll', this.handleScroll);
};
removeScrollEvent = () => {
this.scrollRef.current.removeEventListener('scroll', this.handleScroll);
};
render() {
return (
<div className={c('Search-flight-builder')} ref={this.scrollRef}>
<p>Hello</P
</div>
);
}
};
export default SearchFlightBuilder;
And testing file looks like this -
import React from 'react';
import { shallow, mount, render } from 'enzyme';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import SearchFlightBuilder from './SearchFlightBuilder';
configure({ adapter: new Adapter() });
const testFlightBuilder = () => <SearchFlightBuilder />;
describe('SearchFlightBuilder', () => {
it('should render correctly', () => {
const component = shallow(<SearchFlightBuilder />);
expect(component).toMatchSnapshot();
});
});
When I am running the tests, I am getting this error -
TypeError: Cannot read property 'addEventListener' of null. I tried various approaches, but none of the approach works. Please help me here. I am using enzyme library here.
Here is my unit test strategy:
index.tsx:
import React, { PureComponent } from 'react';
class SearchFlightBuilder extends PureComponent {
scrollRef: any = React.createRef();
state = {
loading: true,
error: false,
filteredList: [],
pageIndex: 0,
scrollCalled: false
};
handleScroll = event => {
// make sure scroll should be called once
if (
this.scrollRef.current.scrollTop + this.scrollRef.current.clientHeight >= this.scrollRef.current.scrollHeight &&
!this.state.scrollCalled
) {
this.setState({
pageIndex: this.state.pageIndex + 1
});
this.setState({ scrollCalled: true });
}
};
componentDidMount = () => {
this.scrollRef.current.addEventListener('scroll', this.handleScroll);
};
removeScrollEvent = () => {
this.scrollRef.current.removeEventListener('scroll', this.handleScroll);
};
render() {
return (
<div className="Search-flight-builder" ref={this.scrollRef}>
<p>Hello</p>
</div>
);
}
}
export default SearchFlightBuilder;
Since clientHeight and scrollHeight properties are read-only, so they need to be mocked using Object.defineProperty.
index.spec.tsx:
import React from 'react';
import { shallow } from 'enzyme';
import SearchFlightBuilder from './';
describe('SearchFlightBuilder', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should handle scroll, pageindex + 1', () => {
const mDiv = document.createElement('div');
const events = {};
const addEventListenerSpy = jest.spyOn(mDiv, 'addEventListener').mockImplementation((event, handler) => {
events[event] = handler;
});
mDiv.scrollTop = 1;
Object.defineProperty(mDiv, 'clientHeight', { value: 1 });
Object.defineProperty(mDiv, 'scrollHeight', { value: 1 });
const mRef = { current: mDiv };
const createRefSpy = jest.spyOn(React, 'createRef').mockReturnValueOnce(mRef);
const component = shallow(<SearchFlightBuilder />);
expect(createRefSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledWith('scroll', component.instance()['handleScroll']);
events['scroll']();
expect(component.state('pageIndex')).toBe(1);
expect(component.state('scrollCalled')).toBeTruthy();
});
});
Unit test result with coverage report:
PASS src/stackoverflow/57943619/index.spec.tsx (8.618s)
SearchFlightBuilder
✓ should handle scroll, pageindex + 1 (15ms)
-----------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------|----------|----------|----------|----------|-------------------|
All files | 94.44 | 85.71 | 80 | 93.75 | |
index.tsx | 94.44 | 85.71 | 80 | 93.75 | 32 |
-----------|----------|----------|----------|----------|-------------------|
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 9.916s
Source code: https://github.com/mrdulin/jest-codelab/tree/master/src/stackoverflow/57943619