Simplest testing library for react hooks.
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
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";
// Create your hook
const useCounter = (initial = 0, inc = 1) => {
const [count, setCount] = useState(initial);
const inc = () => setCount(count + inc);
return {
count,
inc,
};
};
//Proxy of your hook, use it like you would in a component.
//Internally ensures your hook is rendered inside of a component with react-test-renderer
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);
}
});
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.
Main entry point of the project is the createTestProxy function.
For the rest of the api documentation click here.
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.
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);
// or createTestProxy(useHook, optionsObject);
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
.
As a style choice replacing
use
withprx
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", () => {
//Code blocks are useful to avoid symbol clashing
{
//This is just the useState hook, use it the same way you would useState
const [value, setValue] = prxState(1);
expect(value).toBe(1);
setValue(2);
//FYI `value` is still 1 here, same behavior as react
}
{
//Next render of the hook
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.
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.
The second element returned by createTestProxy
in a control object.
The control, as the name suggest, allows external control to the proxy hook.
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.
//Takes a function in as a dependency, calls it and then sets the value when it resolves.
//Note don't actually use this hook, it does no error handling and doesn't check it mounted
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);
}
//Wait for the component to finish updating.
await control.waitForNextUpdate();
{
const result = prxAsync(fn);
expect(result).toBe(1);
}
});
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",
},
};
//Note that there is no default value so it'll return undefined
const ThemeContext = React.createContext();
const [prxContext, control] = createTestProxy(useContext, {
//Wrapper has to render it's children or the hook won't work
//If in strict mode it'll throw an error on call, if not it'll print a warning
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);
}
//Doesn't force a render next call to hook to render
control.wrapper = ({ children }) => (
<ThemeContext.Provider value={themes.dark}>
{children}
</ThemeContext.Provider>
);
{
const result = prxContext(ThemeContext);
expect(result).toEqual(themes.dark);
}
});
//Note that if the wrapper is passed in as a parameter on the config object on reset on cleanup
it("will get the value from the wrapper in config", () => {
const result = prxContext(ThemeContext);
expect(result).toEqual(themes.light);
});
Control object also exposes an unmount
function, that as the name suggests unmounts the component.
//Calls the function passed in on unmount
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();
});
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.
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.
//Records the execution state of the function, the function used as the key.
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);
{
//Suspends here which might be a bit surprising
prxAsyncSuspense(getStuff));
}
//Wait for the hook to complete updating
await control.waitForNextUpdate();
{
const result = prxAsyncSuspense(getStuff);
expect(result).toBe(1);
}
});
What's the caveat then? Take a closer look at the first call to the proxy.
//Didn't this function throw on first call to suspend itself? What does this return then?
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";
//Using the code mentioned above
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);
}
});
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.
//When argument determines where in the lifecycle to the error should throw
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", () => {
//Error directly on render
expect(() => prxError("render")).toThrowError("render");
});
it("will throw after mount", () => {
//Error just after mount
expect(() => prxError("aftermount")).toThrowError("aftermount");
});
it("will throw on unmount", () => {
prxError("unmount");
//Even on unmount it'll hoist any errors caught
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));
//Rejects if the suspended promise rejects
await expect(control.waitForNextUpdate()).rejects.toThrowError(
"Boom Suspense",
);
});
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);
}
//It should reject right?
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.
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(() => {
//Don't set if not mounted
if (mounted.current) {
setValue((i) => i + 1);
}
}, i * ms);
}
return () => {
mounted.current = false;
};
}, [ms]);
return value;
}
const [prxBatchAsync, control] = createTestProxy(useBatchAsync);
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();
//Initial value
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);
//Initial value
expect(result).toEqual(0);
}
//Will now wait for 6ms before resolving
await control.waitForNextUpdate().debounce(6);
{
const result = prxBatchAsync(5);
expect(result).toEqual(3);
}
});
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);
}
});
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);
}
//Unlike debounce just waits for a straight 10ms and resolves, won't wait for the first update event
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.
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()
//Don't forget to add async to turn this into a promise
.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);
}
});
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", () => {
{
// Renders the hook inside of a component using 'react-test-renderer'
// let root;
// TestRenderer.act(() => root = TestRenderer.create(<CallbackComponent />));
const { count, inc } = prxCounter();
expect(count).toBe(0);
// Function calls are wrapped TestRenderer.act(() => inc());
inc();
}
{
// TestRenderer.act(() => root.update(<CallbackComponent />);
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.