Description
Custom React Hooks are very hard to test. There are a few articles dedicated to this, but they all come down to the same solution: instantiating a fake component that is using the hook, and testing that the hook is working through that component.
The premise of this testing library is that this is slow, hard to setup, and fails when you use useEffect in an asynchronous manner.
So the solution to that, that this library brings, is to completely mock the React Hooks API, and replace it by fake implementations (that you control).
That way you can test your hooks in isolation, and you don't need the overhead of instanciating React Components and a DOM just to test a small function.
Jooks (Jest β€ + Hooks π€π») alternatives and similar libraries
Based on the "Dev Tools" category.
Alternatively, view Jooks (Jest β€ + Hooks π€π») alternatives based on common mentions on social networks and blogs.
-
https://github.com/microsoft/playwright
Playwright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API. -
Handsontable
JavaScript data grid with a spreadsheet look & feel. Works with React, Angular, and Vue. Supported by the Handsontable team β‘ -
react-testing-library
π Simple and complete React DOM testing utilities that encourage good testing practices. -
reactotron
A desktop app for inspecting your React JS and React Native projects. macOS, Linux, and Windows. -
React PWA
An upgradable boilerplate for Progressive web applications (PWA) with server side rendering, build with SEO in mind and achieving max page speed and optimized user experience. -
Reactime 6.0: State Debugger for React
Developer tool for time travel debugging and performance monitoring in React applications. -
Universal Data Tool
Collaborate & label any type of data, images, text, or documents, in an easy web interface or desktop app. -
carte-blanche
DISCONTINUED. An isolated development space with integrated fuzz testing for your components. See them individually, explore them in different states and quickly and confidently develop them. -
redux-test-recorder
a redux middleware to automatically generate tests for reducers through ui interaction -
react-heatpack
DISCONTINUED. A 'heatpack' command for quick React development with webpack hot reloading. -
#<Sawyer::Resource:0x00007f8b44d36950>
εΊδΊReactεΌεηζ°δΈδ»£webθ°θ―ε·₯ε ·οΌζ―ζReactη»δ»Άθ°θ―οΌη±»δΌΌδΊChrome DevtoolsγA Lightweight, Easy To Extend Web Debugging Tool Build With React -
unexpected-react
Plugin for http://unexpected.js.org to enable testing the full React virtual DOM, and also the shallow renderer -
BundleMon
A free open-source tool that helps you to monitor your bundle size on every commit and alerts you on changes. -
SimpleLocalize
SimpleLocalize CLI is a developer-friendly command-line tool for uploading and downloading translation files -
react-demo-tab-cli
DISCONTINUED. β‘ Create React components demos in a zap [Moved to: https://github.com/mkosir/demozap] -
react-redux-api-tools
A set of tools to facilitate react-redux development and decouple logic from compontents
InfluxDB - Purpose built for real-time analytics at any scale.
* Code Quality Rankings and insights are calculated and provided by Lumnify.
They vary from L1 to L5 with "L5" being the highest.
Do you think we are missing an alternative of Jooks (Jest β€ + Hooks π€π») or a related project?
README
Jooks (Jest β€ + Hooks π€π»)
If you're going through hell testing React Hooks, keep going. (Churchill)
What are Custom React Hooks
React Hooks are a new API added to React from version 16.8.
They are great, and make proper separation of concern and re-using logic across components very easy and enjoyable.
One problem: they are f*ing hard to test.
Let's start with a definition first: Custom React Hooks (CRH) are functions, starting with use
(by convention), that are themselves using React's Hooks (useState
, useEffect
and so on). They are standalone, and not part of a component.
Why this library?
Custom React Hooks are very hard to test. There are a few articles dedicated to this, but they all come down to the same solution: instantiating a fake component that is using the hook, and testing that the hook is working through that component.
The premise of this testing library is that this is slow, hard to setup, and fails when you use useEffect
in an asynchronous manner.
The solution
So the solution to that, that this library brings, is to completely mock the React Hooks API, and replace it by fake implementations (that you control).
That way you can test your hooks in isolation, and you don't need the overhead of instantiating React Components and a DOM just to test a small function.
Prerequisites
This library works with Jest.
Current capabilities
Currently, the library supports most of React's basic hooks:
- useState
- useEffect
- useContext
- useReducer
- useCallback
- useMemo
- useRef
useImperativeHandle(Coming Soon!)- useLayoutEffect
- useDebugValue
Installation
yarn add jooks
Examples
Simple example with useState only
Let's take this very simple hook:
const useStateOnlyExample = () => {
const [first, setFirst] = useState('alpha');
const [second, setSecond] = useState('beta');
const [third, setThird] = useState(() => 'charlie'); // Notice the delayed execution
const update = () => {
setFirst(first + 'a');
setSecond(second + 'b');
setThird(third + 'c');
};
return { first, second, third, update };
};
To test this (mostly useless) CRH, here is what you need to do:
import 'jest';
import init from 'jooks';
import useStateOnlyExample from '../useStateExample';
describe('Testing useState hook', () => {
// Initialising the Jooks wrapper
const jooks = init(() => useStateOnlyExample());
it('It should give the correct initial values', () => {
// Run your Hook function
const { first, second } = jooks.run();
// And then test the result
expect(first).toBe('alpha');
expect(second).toBe('beta');
});
it('It should update the values properly', () => {
// Run your Hook function
let { first, second, third, update } = jooks.run();
expect(first).toBe('alpha');
expect(second).toBe('beta');
expect(third).toBe('charlie');
// Call the callback
update();
// Run the Hook again to get the new values
({ first, second, third } = jooks.run());
expect(first).toBe('alphaa');
expect(second).toBe('betab');
expect(third).toBe('charliec');
});
});
As you can see, testing the hook was mostly painless.
A more complicated example involving useEffect
This hook is a more real-world example. It fetches data (once), stores it into the state, and returns that data.
It also provides a callback to fetch another set of data.
The example is using TypeScript, but this would of course work with vanilla JavaScript.
import { useEffect, useState } from "react";
interface Activity {
activity: string;
accessibility: number;
type: string;
participants: number;
price: number;
key: string;
}
export default () => {
const [activity, setActivity] = useState<Activity | null>(null);
useEffect(() => {
const fetchData = async () => {
const result = await fetch("https://www.boredapi.com/api/activity");
if (result.ok) {
const content = (await result.json()) as Activity;
setActivity(content);
}
};
fetchData();
}, []);
return { activity, next: fetchData };
};
The problem with that example, is that it would be impossible to test with other techniques, because of the asynchronous content of the useEffect
function.
It acts in a "fire and forget" way, which means that wrapping the call in act()
is not going to work: act()
is synchronous, and the asynchronous function inside will resolve outside of it at a later point, resulting in an error in the console.
So how do I test that with Jooks?
import 'jest';
import init from 'jooks';
import useLoadActivity from '../useLoadActivity';
// This library helps you with testing fetch, but you can use other methods to achieve the same thing
import { GlobalWithFetchMock } from 'jest-fetch-mock';
// This is a TypeScript specific way of mocking the fetch function.
// See the jest-fetch-mock documentation for more information.
const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
customGlobal.fetch = require('jest-fetch-mock');
customGlobal.fetchMock = customGlobal.fetch;
describe('Testing a custom hook', () => {
const jooks = init(() => useLoadActivity());
beforeEach(() => {
customGlobal.fetch.mockResponses(
// On the first call, the endpoint will return Foo
[JSON.stringify({ activity: 'Foo' }), { status: 200 }],
// And Bar on the second call
[JSON.stringify({ activity: 'Bar' }), { status: 200 }]
);
});
afterEach(() => {
customGlobal.fetch.mockClear();
});
it('Should load activities properly', async () => {
// By default, before it has a change to load anything, the hook
// will return a null
expect(jooks.run().activity).toBeNull();
// Then we wait for the component to "mount", essentially giving
// it time to resolve the effect and load the data
await jooks.mount();
expect(jooks.run().activity).not.toBeNull();
expect(jooks.run().activity!.activity).toBe('Foo');
// Then we call the next() callback
await jooks.run().next();
expect(jooks.run().activity).not.toBeNull();
expect(jooks.run().activity!.activity).toBe('Bar');
});
});
How to work with useContext
When working with useContext
, you need to set that context first, in your test.
Since Hooks rely on the order they are called, you can safely call setContext
on your jooks wrapper, once per useContext
call, in the same order.
Let's see an example:
import { useContext } from 'react';
import { Context1, Context2 } from './ExampleContext';
const useContextExample = () => {
const { foo } = useContext(Context1);
const { ping } = useContext(Context2);
return foo + ':' + ping;
};
export default useContextExample;
And the test:
import 'jest';
import useContextExample from '../useContextExample';
import { Context1, Context2 } from '../ExampleContext';
import init from 'jooks';
describe('Testing useContext hook', () => {
const jooks = init(() => useContextExample());
beforeEach(() => {
// First you need to set all contexts, in the same order they are called, for every test.
jooks.setContext(Context1, { foo: 'baz' });
jooks.setContext(Context2, { ping: 'pung' });
});
it('It should give the correct values', () => {
// Then you can run your hook normally and expect that `useContext`
// will return the value you set in the beforeEach
const foo = jooks.run();
expect(foo).toBe('baz:pung');
});
it('It should give the correct values when set within the test', () => {
// If you want to change that context for a specific test, you can always reset the context values
// and set new ones.
jooks.resetContext();
jooks.setContext(Context1, { foo: 'buz' });
jooks.setContext(Context2, { ping: 'pang' });
const foo = jooks.run();
expect(foo).toBe('buz:pang');
});
});
Custom hooks with arguments
Hooks often rely on passed-in arguments to compute a value, for example:
import { useContext } from 'react';
import { Context1 } from './ExampleContext';
const useContextWithArgsExample = bar => {
const { foo } = useContext(Context1);
return foo + ':' + bar;
};
export default useContextWithArgsExample;
To test these, we can simply pass in the argument when we call .run()
:
import 'jest';
import useContextWithArgsExample from '../useContextWithArgsExample';
import { Context1 } from '../ExampleContext';
import init from 'jooks';
describe('Testing useContextWithArgsExample hook', () => {
const jooks = init(useContextWithArgsExample);
beforeEach(() => {
jooks.setContext(Context1, { foo: 'baz' });
});
it('It should give the correct values', () => {
// Here it should compute the hook's return value based on what you passed in
const foo = jooks.run('bar');
expect(foo).toBe('baz:bar');
});
});
API
The library exposes 3 things:
- The default export is an initialisation function, as shown in the examples above, hidding the complexity away. This is Jest-specific.
Jooks
class: this is the class that contains the logic and wraps your hook. It is independent from any testing framework so it could be used with other testing frameworks.
function init<T>(hook: (...args: any[]) => T, verbose?: boolean)
(default export)
This function is meant to be called within your test's describe
function.
It takes one compulsory argument, and one optional flag:
hook
: This is a function that calls your hook, or the hook function itselfverbose
(optional): Whether to enable the verbose mode, logging information to the console, for debugging purpose.
const jooks = init(() => useContextExample(someVariable));
// or
const jooks = init(useContextExample);
or, to enable the verbose mode;
const jooks = init(() => useContextExample(), true);
// or
const jooks = init(useContextExample, true);
Two ways to initialise Jooks
As see above, you have two ways of initialising a hook :
In the first one, you instantiate your hook on initialisation, with function arguments that are not going to change for the rest of the test:
const jooks = init(() => useContextExample(someVariable));
jooks.run();
Alternatively, you can specify the Hook function directly, and provide its argument on each run()
call, like so:
const jooks = init(useContextExample);
jooks.run(someVariable);
Jooks
This is the object you get on the third parameter of the describe function.
It exposes 3 methods that you should care about:
async mount(wait: number = 1): Promise<R>
: ensure your hook ran all its effect "on mount"run(...hooksParams?)
: this runs your hook function and returns the result. This is usually what you are testing.async wait(wait: number = 1)
: if you are expecting an asynchronous effect to fire, call this to wait until it's resolved
An additional 2 methods are dedicated to Context:
setContext<T>(context: React.Context<T>, value: T)
: Allows you to set the context before it's used withuseContext
.resetContext()
: Resets all previously set contexts
There are 2 other public method that you shouldn't use if you are using the init function as described above.
setup
: this is to be run before every test. This is done automatically if you are using the init function.cleanup
: this is to be run after every test. This is done automatically if you are using the init function.
async mount(wait: number = 1): Promise
Use this function at the beginning of a test to wait for useEffect
to fire.
A classic way of using this is: testing that the default values are fine, then wait for the API call that will populates with real data from the backend.
If for some reason the effect doesn't resolve quickly enough, you can increase the timeout time using the optional parameter: await mount(5);
.
it("Should load activities properly", async () => {
// By default, before it has a change to load anything, the hook
// will return a null
expect(jooks.run().activity).toBeNull();
// Then we wait for the component to "mount", essentially giving it time to resolve the
// effect and load the data
await jooks.mount();
expect(jooks.run().activity).not.toBeNull();
expect(jooks.run().activity!.activity).toBe("Foo");
});
run(...hooksParams?)
This function runs your hook function. Use this to test the hook output.
async wait(wait: number = 1)
If you know that a change you make (say calling one of the hook callbacks) is going to result in an async callback or useEffect to be called, use this to wait until the effect has resolved. You can extend the wait time, but the default (1) should be enough if you properly mock your API calls. It will fire all effects and then wait.
setContext(context: React.Context, value: T)
This is only necessary when your Hook uses useContext
. You need to call this as many times you have a useContext
call, and in the same order. First, provide your context object, and then it's value.
Future
- Making Jooks compatible with other testing frameworks
- Allowing a better control on the Hook's internal state
FAQ
- Why Jooks? it's a mix of Jest and Hooks.
- Can I use this? Since it's a testing library (and not a piece of code that's going to production), yes go ahead. The API is likely to change a bit before being stable though, so you might want to pin down your version.
- There must be a Medium article about that? Yes, there is.
Thanks
- Thanks to @bronter for suggesting and implementing the ability to specify a hook function parameters on
run()
instead of just during initialisation. - Thanks to @jjd314 for implementing a fix around saving run() arguments
- Thanks to @hjvvoorthuijsen for improving the
setState
mock and allowing the use of a callback function.
Can I contribute?
Yes please! MR welcome.