Testing React Hooks without a DOM

Image of Author
October 21, 2022 (last updated April 28, 2023)

Quick Summary

Use node:test (nodejs v18+), node:assert, react-test-renderer (official React library), and circumvent jsx parsing with createElement (React API).

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement, useState } from "react";
import { create } from "react-test-renderer";

describe("hook", () => {
  it("works", () => {
    function Component() {
      const [state, setState] = useState(1);
      useEffect(() => setState(2), []);
      return createElement("div", { state }, null);
    }

    const c = createElement(Component, null, null);
    const testRenderer = create(c);
    let actual = testRenderer.root.findByType("div").props.state;

    assert.deepEqual(actual, 1);

    testRenderer.update(c);
    actual = testRenderer.root.findByType("div").props.state;

    assert.deepEqual(actual, 2);
  });
});

Introduction

I have made a few npm libraries that are just a custom hook. They are so simple that I don't want to bring in a testing library and/or setup a complex babel pipeline. In fact, I don't want to render anything. I really just want to check that my hook inputs create the hook outputs I desire. I want to share an interesting way to do this.

Background

Node version 18 introduced a builtin test runner.

react-test-renderer is an official React library. The test renderer lets you "render React components to pure JavaScript objects". You do not need a browser or jsdom, i.e., no DOM!

React hooks typically encapsulate logic. Logic without the complexities of UI is amenable to unit testing. Unit testing custom React hooks is, therefore, desirable and sometimes DOM-independent.

The jsx problem

I am writing my components in TypeScript, .ts and .tsx files. Compiling those files with tsc creates .js output files. (Alternatively, you could write .jsx files and transpile them to .js files.) In order to test hooks inside those files, you would import them into, say, a .test.js file. In order for those hooks to work, they need to be inside React components. So, in your test files, you put them inside React components. But, now you have a problem. A jsx problem.

The problem is this: You have a simple test suite that has jsx inside test files and you don't want to compile/transpile those tests.

Is the really a problem? Yes, because supporting compilation is non-trivial. It requires babel, or tsc (TypeScript Compiler), or one of a few other compilation/transpilation utilities, etc. If you already have to go to this effort for you library, then you might not have a problem. That said, incorporating your test suite into this compilation pipeline in a reasonable way is already difficult. Doing it just for your test suite feels bad.

The easiest way around the jsx problem is to avoid it. You can skip the compilation phase if you write React without JSX. This is probably not a good idea for complex testing requirements. But, for testing a simple, custom react hook, it is doable.

The test

Let's say I have a custom hook called useStore which contains a merge function. This is how I could test it:

test("it merges", () => {
  function Component() {
    const { state, merge } = useStore({ a: 1 });
    useEffect(() => merge({ a: 3 }), []);
    return createElement("div", { a: state.a }, null);
  }

  const c = createElement(Component, null, null);
  const testRenderer = create(c);
  let actual = testRenderer.root.findByType("div").props.a;

  assert.deepEqual(actual, 1);

  testRenderer.update(c);
  actual = testRenderer.root.findByType("div").props.a;

  assert.deepEqual(actual, 3);
});

It's a normal React component, just without a jsx return value. I then createElement the component and pass it to the test renderer. At this point I can use the tree-traversal utility findByType to find the div component and check its prop. This div's prop is effectively the output under test, it is the effect the hook has on the React DOM-like tree. I then update the element in the test renderer, which executes the elements useEffect hook. This calls my merge function and updates state. I again check the div's prop to make sure the updated state is as expected. This is a single render-update-render loop. The function "under test" is merge.

Other approaches

I have considered other approaches. One such approach was isolating the core logic into a pure function and testing that. I could then wrap that pure function in a hook. I think this is a viable approach in the right circumstances. You could then (optionally) write a test to ensure you "hooked" it correctly, but still have the majority of your test suite devoted to the logic of the pure function. This pure approach is compatible with the DOMless approach, in that you can test that you "wired up" your pure function to the hook APIs correctly, but otherwise use your preferred testing suite for the pure function.

A solution I would likely reach for if I wanted to develop a sophisticated custom hook is the one articulated by Kent C Dodds in his article How to test custom React hooks, which leverages React Testing Library.

Another solution is using Jest snapshots which use react-test-renderer toJSON as the snapshot target. This is very similar to my approach expect that they will also have you construct a test-file processing pipeline so that you can write jsx.

If you don't think you need a dom library, compilation library, and testing library, all to test a simple hook, then you could consider this DOMless approach.

Conclusion

I'm curious to see how difficult it is to work with this test setup going forward. I will be using it for some simple custom hooks I'm writing. I'm not sure if it's a good idea, but it's definitely an interesting idea.

I am currently using it in @aegatlin/react-store if you want to see it in action on a simple custom hook library written in TypeScript.

Thanks for reading :D