test-react-hooks ⚓️
Simplest testing library for react hooks.

Contents
Installing
To install add test-react-hooks
and it's peer dependencies react
and react-test-renderer
.
Depending on your package manager run one of the following commands.
yarn add test-react-hooks react react-test-renderer -D
npm i test-react-hooks react react-test-renderer --save-dev
Quick Start
The entry point for test-react-hooks
is createTestProxy
method that returns a tuple with two elements.
The first element is the proxied hook and the second is a control object but let's ignore that for now.
import { createTestProxy } from "test-react-hooks";
import { useState } from "react";
const useCounter = (initial = 0, inc = 1) => {
const [count, setCount] = useState(initial);
const inc = () => setCount(count + inc);
return {
count,
inc,
};
};
const [prxCounter] = createTestProxy(useCounter);
it("will increment by one", () => {
{
const { count, inc } = prxCounter();
expect(count).toBe(0);
inc();
}
{
const { count } = prxCounter();
expect(count).toBe(1);
}
});
it("start with a new initial amount", () => {
{
const { count, inc } = prxCounter(4);
expect(count).toBe(4);
inc();
}
{
const { count } = prxCounter(4);
expect(count).toBe(5);
}
});
it("will increment by a new amount", () => {
{
const { count, inc } = prxCounter(0, 2);
expect(count).toBe(0);
inc();
}
{
const { count } = prxCounter(0, 2);
expect(count).toBe(2);
}
});
Examples
Example usage can be found at this in the examples directory on the repo.
Or click on the below link to a sandbox with the above examples.

Api Documentation
Main entry point of the project is the createTestProxy function.
For the rest of the api documentation click here.
Why use test-react-hooks?
Testing by it's very nature in in inherently filled with uncertainty.
The goal of test-react-hooks
is to remove that uncertainty by giving an api that feels like just using the hook with as little api between you and it.
It also not silent about when things go wrong, a goal for this library is to break where an error occurs.
Error handling will be covered in more detail below.
Overall it attempts to get out of your way without any surprises on how to write your tests or how they'll behave when used in react.
Slower start
The main entry point for test-react-hooks
is createTestProxy.
It takes in two arguments the first is the hook that you'll want to test and the second is an optional options argument.
import { useState } from "react";
import { createTestProxy } from "test-react-hooks";
const [prxState, control] = createTestProxy(useState);
createTestProxy
returns a tuple with two elements the first is a proxied version of your hook and the second is a control object.
As a naming convention use
is replaced with prx
.
A note on naming
As a style choice replacing use
with prx
gives a hint at a relation to the original hook, stops the warning from eslint and avoids a symbol clash.
With that being said the name has no technical requirement so naming it what you wish is fine.
Calling the proxied hook will force a render or update the underlying component and return the hook results.
Given the above example here is how you'd test a state update.
it("will update state", () => {
{
const [value, setValue] = prxState(1);
expect(value).toBe(1);
setValue(2);
}
{
const [value, setValue] = prxState(1);
expect(value).toBe(2);
}
});
As you can see the usage of the proxied version of useState
is the same as you would do in react.
This would suggest that a call to the proxied hook is stateful and it is.
Cleanup
test-react-hooks
exports a cleanUp
function that needs to be called between tests.
Proxied hooks can safely be shared across multiple tests as long as the cleanUp
function is called between tests resetting it's state.
test-react-hooks
will look for an afterEach
function on the global scope when imported and register the cleanup function.
In most cases this will be done for you and if it's not a warning explaining that cleanUp
needs to be called will be printed.
If for some reason you want to disable this behavior define a variable on the environment TEST_REACT_HOOKS_NO_CLEANUP
but be warned you'll probably break all your tests.
Control Object
The second element returned by createTestProxy
in a control object.
The control, as the name suggest, allows external control to the proxy hook.
Async
When writing an async test the issue is to wait for the next update.
This external control object exposes a method waitForNextUpdate
that by default returns a promise that resolves when the component to stops updating for 3ms
.
This should cover most uses cases but if you'll need more control of the wait behavior read on to the advanced async section.
function useAsync(fn) {
const [value, setValue] = useState(null);
useEffect(() => {
fn().then(setValue);
}, [fn]);
return value;
}
const [prxAsync, control] = createTestProxy(useAsync);
it("will wait for the value to update", async () => {
const fn = () => Promise.resolve(1);
{
const result = prxAsync(fn);
expect(result).toBe(null);
}
await control.waitForNextUpdate();
{
const result = prxAsync(fn);
expect(result).toBe(1);
}
});
Wrapper Component
In some cases you'll need to render the hook as child of another component, normally when using context.
There are two options here, it can be passed in as an option when calling createTestProxy
or updated by setting the wrapper
property on the control object.
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee",
},
dark: {
foreground: "#ffffff",
background: "#222222",
},
};
const ThemeContext = React.createContext();
const [prxContext, control] = createTestProxy(useContext, {
wrapper: ({ children }) => (
<ThemeContext.Provider value={themes.light}>
{children}
</ThemeContext.Provider>
),
});
it("will update the wrapper in the control object", () => {
{
const result = prxContext(ThemeContext);
expect(result).toEqual(themes.light);
}
control.wrapper = ({ children }) => (
<ThemeContext.Provider value={themes.dark}>
{children}
</ThemeContext.Provider>
);
{
const result = prxContext(ThemeContext);
expect(result).toEqual(themes.dark);
}
});
it("will get the value from the wrapper in config", () => {
const result = prxContext(ThemeContext);
expect(result).toEqual(themes.light);
});
Unmount
Control object also exposes an unmount
function, that as the name suggests unmounts the component.
function useOnUnmount(cb) {
useEffect(() => {
return () => {
cb();
};
}, [cb]);
}
const [prxOnUnmount, control] = createTestProxy(useOnUnmount);
it("will call the callback on unmount", () => {
const unmountSpy = jest.fn();
prxOnUnmount(unmountSpy);
expect(unmountSpy).not.toHaveBeenCalled();
control.unmount();
expect(unmountSpy).toHaveBeenCalled();
});
Advanced
Everything below is quite technical and probably not necessary to understand for basic testing.
If you've not written any tests yet go away install test-react-hooks
and write some tests.
Suspense
Here is the documentation for it but a
a quick intro on how suspense works it's a promise thrown during render.
React will use the promise to determine when to stop suspending.
Suspense is tested in the essentially the same way that async hooks are but with a caveat.
const RESULT_CACHE = new Map();
export function useAsyncSuspense<TResult>(fn: () => Promise<TResult>): TResult {
if (!RESULT_CACHE.has(fn)) {
const execution = fn()
.then((result) => {
RESULT_CACHE.set(fn, { type: "completed", result });
})
.catch((error) => {
RESULT_CACHE.set(fn, { type: "error", error });
});
RESULT_CACHE.set(fn, { type: "running", execution });
}
return useMemo(() => {
const state = RESULT_CACHE.get(fn)!;
switch (state.type) {
case "running":
throw state.execution;
case "error":
throw state.error;
default:
return state.result;
}
}, [fn]);
}
afterEach(() => {
RESULT_CACHE.clear();
});
const [prxAsyncSuspense, control] = createTestProxy(useAsyncSuspense);
it("will wait for suspense", async () => {
const getStuff = () => Promise.resolve(1);
{
prxAsyncSuspense(getStuff));
}
await control.waitForNextUpdate();
{
const result = prxAsyncSuspense(getStuff);
expect(result).toBe(1);
}
});
Suspense Caveat
What's the caveat then? Take a closer look at the first call to the proxy.
prxAsyncSuspense(getStuff));
To signify that the hook is now in a suspended state test-react-hooks
exports a symbol SUSPENDED
.
If you absolutely need to check that the hook is in a suspended state you can check against the SUSPENDED
symbol.
import { SUSPENDED } from "test-react-hooks";
it("will return suspense", async () => {
const getStuff = () => Promise.resolve(1);
{
const result = prxAsyncSuspense(getStuff);
expect(result).toBe(SUSPENDED);
}
await control.waitForNextUpdate();
{
const result = prxAsyncSuspense(getStuff);
expect(result).toEqual(1);
}
});
Errors
This is test-react-hooks
party trick.
Unlike other react hook testing libraries test-react-hooks
will hoist errors to the caller.
It's hugely important that tests don't surprise with hidden exceptions, instead throwing to the call.
Let's have a look at all the situations this could happen.
function useError(when) {
if (when === "render") throw new Error(when);
useEffect(() => {
if (when === "aftermount") throw new Error(when);
return () => {
if (when === "unmount") {
throw new Error(when);
}
};
}, [when]);
}
const [prxError, control] = createTestProxy(useError);
it("will throw straight away", () => {
expect(() => prxError("render")).toThrowError("render");
});
it("will throw after mount", () => {
expect(() => prxError("aftermount")).toThrowError("aftermount");
});
it("will throw on unmount", () => {
prxError("unmount");
expect(() => control.unmount()).toThrowError();
});
It'll also hoist errors from returned functions.
function useMemberError() {
function fnThrow() {
throw new Error("boom");
}
return {
fnThrow,
deep: {
nested: {
fnThrow,
},
},
};
}
const [prxMemberError] = createTestProxy(useMemberError);
it("will throw on member call", () => {
const { fnThrow } = prxMemberError();
expect(fnThrow).toThrowError("boom");
});
it("will throw on deep member call", () => {
const result = prxMemberError();
expect(result.deep.nested.fnThrow).toThrowError("boom");
});
It'll even hoist from a reducer calls.
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
case "throw":
throw new Error("Boom");
default:
return state;
}
}
const [prxReducer] = createTestProxy(useReducer);
it("will handle multiple dispatches", () => {
const [, dispatch] = prxReducer(reducer, initialState);
dispatch({ type: "increment" });
dispatch({ type: "increment" });
dispatch({ type: "increment" });
dispatch({ type: "decrement" });
const [state] = prxReducer(reducer, initialState);
expect(state.count).toBe(2);
});
it("will catch the error", () => {
const [, dispatch] = prxReducer(reducer, initialState);
expect(() => dispatch({ type: "throw" })).toThrowError("Boom");
});
Recall the suspense example above.
If the suspended promise ultimately rejects then waitForNextUpdate
will reject.
it("will throw on waitForNextUpdate if suspense rejects", async () => {
const throwStuff = () => Promise.reject(new Error("Boom Suspense"));
prxAsyncSuspense(throwStuff));
await expect(control.waitForNextUpdate()).rejects.toThrowError(
"Boom Suspense",
);
});
Errors Caveat
Recall the async example above, what happens if the promise rejects?
it("will not work on unhandled promise rejections", async () => {
const throwFn = () => Promise.reject(new Error("Boom Async"));
{
const result = prxAsync(throwFn);
expect(result).toBe(null);
}
await expect(control.waitForNextUpdate()).rejects.toThrowError(
"Boom Async",
);
});
In jest you'll get the following error:
● will not work on unhandled promise rejections
Boom Async
31 |
32 | it("will not work on unhandled promise rejections", async () => {
> 33 | const throwFn = () => Promise.reject(new Error("Boom Async"));
| ^
34 |
35 | {
36 | const result = prxAsync(throwFn);
You're testing framework might differ but overall the behavior should generally be the same.
Why does this not reject on waitForNextUpdate
?
The promise in this case is hidden to both react
and test-react-hooks
.
The only way of determining an unhandled rejection occurred is to listen for process.on('unhandledRejection')
.
You're testing framework would probably already register a listener and would fail the test already at this point.
It's just too invasive for a testing library to mess around with the testing environment itself.
Advanced Async
waitForNextUpdate
doesn't just return a Promise
it returns an instance of UpdateWaiter that extends a Promise
.
Update waiter is a fluent api that can wait for specific changes before resolving.
function useBatchAsync(ms = 1) {
const [value, setValue] = useState(0);
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
if (mounted.current) {
setValue((i) => i + 1);
}
}, i * ms);
}
return () => {
mounted.current = false;
};
}, [ms]);
return value;
}
const [prxBatchAsync, control] = createTestProxy(useBatchAsync);
Debounce
The default waiting method is to debounce for 3ms
in other words it'll wait for updates to stop for 3ms
before resolving. Note it'll still wait for the first update before resolving.
it("will run batch async operations", async () => {
{
const result = prxBatchAsync();
expect(result).toEqual(0);
}
await control.waitForNextUpdate();
{
const result = prxBatchAsync();
expect(result).toEqual(3);
}
});
If you wanted to wait for longer just call the debounce function on the fluent api.
it("will run batch async operations with a longer debounce", async () => {
{
const result = prxBatchAsync(5);
expect(result).toEqual(0);
}
await control.waitForNextUpdate().debounce(6);
{
const result = prxBatchAsync(5);
expect(result).toEqual(3);
}
});
Update Count
If you want to wait for a specific amount of updates before resolving there's an updateCount method.
It takes an optional argument to wait for set amount of updates to occur before resolving.
Update count can make your tests somewhat brittle so use with caution.
it("will wait for each update", async () => {
{
const value = prxBatchAsync();
expect(value).toEqual(0);
}
await control.waitForNextUpdate().updateCount(1);
{
const value = prxBatchAsync();
expect(value).toEqual(1);
}
await control.waitForNextUpdate().updateCount(2);
{
const value = prxBatchAsync();
expect(value).toEqual(3);
}
});
Custom Waiters
It's also possible to write a custom waiter with the addWaiter function.
const wait = (ms = 10 = new Promise((resolve) => setTimeout(resolve, ms)));
it("will use the custom waiter function", async () => {
{
const value = prxBatchAsync();
expect(value).toEqual(0);
}
await control.waitForNextUpdate().addWaiter(() => wait());
{
const value = prxBatchAsync();
expect(value).toEqual(3);
}
});
Custom waiter is passed in a rxjs Observable with an UpdateEvents.
If you're familiar with rxjs
addWaiter
can take anything that rxjs
considers to be ObservableInput.
For the most part a returning a Promise
is probably what you want as internally it'll wait for the first event from a waiting function.
Using Fake Timers
test-react-hooks
is tested using jest
so documented code might differ for your framework of choice.
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
function useWaits() {
const [value, setValue] = useState(0);
wait(1).then(() => setValue(1));
wait(10).then(() => setValue(10));
wait(100).then(() => setValue(100));
return value;
}
beforeEach(() => {
jest.useFakeTimers("modern");
});
afterEach(() => {
jest.useRealTimers();
});
const [prxWaits, control] = createTestProxy(useWaits);
it("can use proxy timer in waiter fn", async () => {
{
const value = prxWaits();
expect(value).toBe(0);
}
await control
.waitForNextUpdate()
.addWaiter(async () => jest.advanceTimersByTime(2));
{
const value = prxWaits();
expect(value).toBe(1);
}
await control
.waitForNextUpdate()
.addWaiter(async () => jest.advanceTimersByTime(10));
{
const value = prxWaits();
expect(value).toBe(10);
}
await control
.waitForNextUpdate()
.addWaiter(async () => jest.advanceTimersByTime(90));
{
const value = prxWaits();
expect(value).toBe(100);
}
});
Technical Bits
What happened to the act
calls?
If you're familiar with react-test-renderer you'll note that updates need to be wrapped in act
.
Where are the calls to act
in test-react-hooks
?
It's where the proxy
part of createTestProxy
comes in.
The hook passed in and any results returned will be proxied using Standard library Proxy.
act
is then called for any proxied function calls. Using the counter example used in the quick start but with annotations.
import { createTestProxy } from "test-react-hooks";
import { useState } from "react";
const useCounter = (initial = 0, inc = 1) => {
const [count, setCount] = useState(initial);
const inc = () => setCount(count + inc);
return {
count,
inc,
};
};
const [prxCounter] = createTestProxy(useCounter);
it("will increment by one", () => {
{
const { count, inc } = prxCounter();
expect(count).toBe(0);
inc();
}
{
const { count } = prxCounter();
expect(count).toBe(1);
}
});
There are some caveats to this that only function calls are wrapped in act.
Namely setters are not wrapped in act so you'll have to wrap anything other than function calls.
With that being said most hooks don't bother with setters but if this is a an annoyance for you open a feature request.