Whistlr
@samthor

Unit Testing React without Jest

Jest is clearly a polished testing tool, but with the advent of Node.js 18, we really don't need it any more. Jest also has a huge surface area, is perhaps inexorably linked to Webpack, and needs its own binary—you can't just "run the tests" as a script. So it has some modern challenges.

This blog post will teach you how to set up your code, rather than provide an all-in-one solution, and is not about React testing itself—just the environment where you can do it. We'll still be using the @testing-library/react dependency, and esbuild to do our build.

As it's just a set up guide, you might still want to write a small wrapper or use a library. But by the time you've finished reading, you should have a better sense of what's going on—unit testing isn't magic ✨, but it is important.

The Steps

Let's get right into it. 🤠

0. Build with esbuild

This guide assumes you're building with esbuild, but you could swap this out for another build tool. You should be able to build your React code already to a regular JS file, with a command something like this:

$ esbuild index.tsx --bundle --format=esm --outfile=dist.js

1. Write a test

Well, you'll need a test. Here's a simple one and the component that's under test:

const FooComponent = ({ text }: { text: string }) => {
  return <div>Hello <span data-testid="hold">{text}</span></div>;
};

test('test component', () => {
  const result = render(
    <FooComponent name={Sam} />,
  );
  // Check that FooComponent renders my name properly.
  const span = result.getByTestId('hold');
  assert.strictEqual(span.textContent, 'Sam');
});

It says hello to someone, and we'll just check that the contents of the element are correct. Sure, it's just for a demo.

So if we add the right imports, and build/run our test, …the test won't run, since we're using a lot of globals that don't exist—render, test, and assert. Let's keep going 👇

2. Add the right imports

Since we're no longer using Jest, we need a couple of important imports—there aren't just magic globals available anymore. Let's add these:

// You may want to / need to import React, depending on build setup
import React from 'react';

// Node's built-in testing libraries
import test from 'node:test';
import assert from 'node:assert';

// We still want a helper, and this is a great one
import { render } from '@testing-library/react';

Great! Try running your script again, …and it still won't run, because document isn't defined. We're not really on the web in a browser, but we can fix that with JSDOM.

3. Add JSDOM

You should add jsdom to your project. You can't just import it and have it be global—it's designed to provide an instance of Window and such that you can pass around.

So, you should add a new file like "global-jsdom.ts" to make it global:

import * as jsdom from 'jsdom';

const j = new jsdom.JSDOM(undefined, {
  // Many APIs are confused without being "on a real URL"
  url: 'http://localhost',
  // This adds dummy requestAnimationFrame and friends
  pretendToBeVisual: true,
});

// We need to add everything on JSDOM's window object to global scope.
// We don't add anything starting with _, or anything that's already there.
Object.getOwnPropertyNames(j.window)
    .filter((k) => !k.startsWith('_') && !(k in global))
    .forEach((k) => global[k] = j.window[k])

// Finally, tell React 18+ that we are not really a browser.
global.IS_REACT_ACT_ENVIRONMENT = true;

There's a few lines there but basically it creates a JSDOM and attaches it to the page. You should not be importing this anywhere but in tests.

Add the import to your test file:

import './global-jsdom';

If you build the test now, you might be in business… but it's likely esbuild or Node will complain about dependencies. Let's take a look.

4. Check your build

We need to update our build command a bit before running the test:

$ esbuild test.tsx --bundle --format=esm --outfile=_test.js \
      --platform=node \
      --external:jsdom
$ node _test.js

The big change here is that as we're using Node's built-in testing library, and JSDOM uses parts of Node, we need to set --platform:node. We also need to mark jsdom as external: it uses some tricky loading code to avoid loading e.g., its HTML <canvas> polyfill if it's not available, and esbuild (maybe others) will try to bundle that rather than letting that tricky code live. (You can add the canvas package to fix this, but it needs a boatload of native dependencies to work. Avoid if you can.)

5. Success!

Hopefully you'll see the testing library do its thing. My output looks something like:

$ esbuild --bundle code-test.tsx --platform=node --format=esm --external:jsdom --outfile=_test.js && node _test.js

  _test.js  1.6mb ⚠️

⚡ Done in 54ms
(node:3855) ExperimentalWarning: The test runner is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
TAP version 13
ok 1 - test component
  ---
  duration_ms: 0.010834917
  ...
1..1
# tests 1
# pass 1
# fail 0
# skipped 0
# todo 0
# duration_ms 0.22451975

…the important part being # pass 1, which means the test worked.

6. Cleanup and helpers

Our library works fine, but there's a big omission from Node's built-in testing library: it won't clean up for us. What this means if that if we run multiple tests in a row, then our JSDOM-created-DOM will get polluted with the output from each individual test.

We can fix this pretty quickly by doing this:

// update our import to include cleanup
import { render, cleanup } from '@testing-library/react';

test('test component', () => {
  cleanup();  // always cleanup first

  const result = render(...);
  assert.strictEqual(result.getByTestId('hold').textContent, 'Sam');
});

But this is a bit annoying to do every time. We might be better off defining a helper in a new file—perhaps throwing in the "global-jsdom.ts" file for good measure—like this:

import './global-jsdom';  // just have to import this _somewhere_
import test from 'node:test';
import { cleanup } from '@testing-library/react';
export { render } from '@testing-library/react';

export const reactTest = (name, fn) => {
  return test(name, () => {
    cleanup();
    return fn();
  });
};

And then you can use that helper, all at once, like this:

import React from 'react';
import { reactTest, render } from './react-test';
import assert from 'node:assert';

reactTest('test component', () => {
  const result = render(...);
  assert.strictEqual(result.getByTestId('hold').textContent, 'Sam');
});

reactTest('test something else', () => {
  const result = render(...);
  assert.strictEqual(...);
});

Hooray! 🥳

Bonus Tips

Context: In my (P)react projects, I tend to have a helper like renderForTest which often sets up a number of context providers that components under test might expect. A good example might be providing flags, or a faked out data provider. So rather than calling render directly, you'd do something like:

export const renderForTest = (children) => {
  // you might need to wrap in the render() method from
  // '@testing-library/react' depending on your use case!
  return <>
    <FakeFooProvider>
      <FlagsProvider flags={{ environment: 'test' }}>
        {children}
      </FlagsProvider>
    </FakeFooProvider>
  </>;
};

You're basically just making sure that your code, which might look for context to source things like flags or other types, always has access to that—perhaps through a provider of a 'fake'.

Mocking: Mocking is hard! I haven't got a complete answer here. One strong advantage of using jest is that it uses CJS by default, and can more easily mock things out. If you're using ESM to build, it's hard or possible impossible to swap out your dependencies. Go on, have a try:

import * as allMethods from 'network-tool';
allMethods.doNetworkThing = () => { ... };
// TypeError: Cannot assign to read only property 'doNetworkThing' of object '[object Module]'

In a 🌈 perfect world 🌈 of abstractions, mocks are unnessecary—you should be replacing e.g., a context object or helper class at once to catch all external API calls. But the reality is any code is going to do various calls to fetch() or whatever.

So, yeah.

If this was important to my projects, I'd probably:

The naïve way to do this looks like this helper:

const isUnderTest = ...;  // maybe check process.env.SOMETHING ?
const fetch = (isUnderTest ? mockedFetch : globalThis.fetch);
export { fetch };

…this could be simplified with conditional exports, but that's a different post. 📬

Done

That's it. I hope you're writing unit tests as we speak.

If you want to learn more about writing React tests, then I suggest:

You can get a huge amount of benefit out of writing unit tests for components without a real browser, and I hope you'll give it a go. 🌇