icon

Speed up react and typescript testing with baretest and es-build

I have to admit that frontend testing is not my favorite thing to spend time on. Frontend tests tend to be fragile and tightly-coupled to non-functional aspects of the code like text labels and classnames. I prefer to write tests for pure functions that calculate or transform data in somewhat complex ways, and most of this happens on the backend. I fully expect developers to write tests for their backend API code, even to the level of testing that ORM models and SQL scripts work as expected.

But still I can’t leave the testing story where I did in the previous post about developing with estrella and es-build. Basically I just left mocha and ts-node as the solution in my sample project. But that’s not a great answer. I have seen that it is a fairly slow and heavy combination of tools. The popular alternative of Jest, Babel and Enzyme may not be fast enough for everyone either.

So I took my philosophy to creating a custom dev and production build script and applied it to the test script. The first step was to identify the minimal testing solution for my needs. Research revealed a new JS testing tool released last year called Baretest.

Minimalism (or when and why to reach for something weird)

Sometimes complex tools are complex for good reasons. Even if I don’t agree with all of the design decisions, I think the React team has built a VDOM library that is going to be the standard for building web applications for a long time to come. React is not minimal like preact – or a dozen other minimal alternatives – but, it creates an abstraction layer that is widely learned and used by many developers. Conceptually very similar to JQuery back in the day, it’s just so widely known that it’s useful as a target for teams to standardize on.

Other times a tool is just the standard because of popularity and there’s room to build something tiny to replace it that meets just the requirements you have. For example React is good, but Create React App installs half of NPM when you start it up. Every dependency and line of code in the dependency is something that theoretically affects the code you have to ship and maintain, including your build-tools. When someone new joins the team, they will have to learn the tools you’ve chosen, so the tools should be small and easy to understand, with a minimum of “magic”.

Baretest is about 50 lines of code with one dependency. It will execute faster and with less overhead than Jest. Instead of babel and tsc we can use es-build, it is faster that babel to compile typescript and JSX into compatible standard JS. Source transpilation and test runner overhead are the two long poles I wanted to address in test startup time for my sample project. Once I’ve seen how small and simple a testing script can be, I will be better informed about reaching for a big library in the future.

Step 1: Write a test with baretest

Here’s my setup helper setup.ts file for creating a dom environment in Node.

1
2
3
4
5
6
7
8
9
import "jsdom-global/register";
import raf from "raf";
export function setup(): HTMLElement | null {
raf.polyfill();
const appDiv = window.document.createElement("div");
appDiv.id = "app";
window.document.body.appendChild(appDiv);
return window.document.getElementById("app");
}

Here are the basic requirements to import

1
2
3
4
5
import assert from "assert";
import { Baretest } from "../src/typings/baretest";
import { render } from "react-dom";
import utils from "react-dom/test-utils";
import { setup } from "./helpers/setup";

Here’s the actual test function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default async (test: Baretest): Promise<void> => {
test("should return hello world", () => {
const rootContainer = setup();

render(
AppComponent({ fancyName: "hello world", isVisible: true }),
rootContainer
);

assert.ok(rootContainer);
const header = rootContainer.querySelector("header");
assert.ok(header);
assert.strictEqual(header.textContent, "hello world");
});
};

And finally a test.ts file that imports and runs all the tests we are going to create.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import baretest from "baretest";
import { Baretest } from "../src/typings/baretest";

const test: Baretest = baretest("Render App");

// A big ol' list of tests to set up and run
// import new tests and call them here
import configureAppTest from "./app.spec";
configureAppTest(test);

!(async function () {
await test.run();
process.exit();
})();

Step 2: Compile tests and source code

To run the tests I create another file in the scripts/ directory that will get built once by es-build and then executed whenever I need to run my tests. This script is about 41 lines of code and 2 external dependencies (+2 transitive deps) to replace Jest (76 deps) and Babel (>15 deps [how to even count everything?]).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/usr/bin/env node
import { BuildConfig } from "estrella";
import { build, glob, cliopts, scandir, file, watch, basename } from "estrella";
import { spawnSync, spawn, exec, execSync } from "child_process";
import Rimraf from "rimraf";
const testOutputDir = "build/_tests/";

/*
* Builds all src and test files then executes the tests whenever it finishes.
* Watch mode is supported with -watch.
*/
!(async function () {
await Rimraf.sync(testOutputDir + "*");
const files = glob("src/**/*.ts*").concat(glob("tests/**/*.ts"));

// the es-build options we will apply to your typescript
const buildOpts: BuildConfig = {
entry: files,
outdir: testOutputDir,
outbase: "./",
format: "cjs",
bundle: false,
debug: true,
sourcemap: true,
minify: false,
silent: true,
tslint: "off",
onEnd: startTests,
};
await build(buildOpts);
})();

/**
* Spawns a node process to run the tests
*/
async function startTests() {
console.log("🎸 Built Source Code");
const time = new Date().getTime();
const nodeTest = spawn(`${process.execPath}`, [`build/_tests/tests/test.js`]);

nodeTest.stdout.on("data", (data) => {
console.log(`[TEST]: ${data}`);
});

nodeTest.stderr.on("data", (data) => {
console.error(`[TEST ERROR]: ${data}`);
});

nodeTest.on("close", (code) => {
console.log(`🎸 Test run finished in ${new Date().getTime() - time}ms`);
if (!cliopts.watch) {
process.exit();
}
});
}

Step 2: Run the tests

1
2
3
4
5
6
7
...
"test": "npm run build:scripts && node build/_scripts/test.js",
"test:quick": "node build/_scripts/test.js",
"test:watch": "npm run build:scripts && node build/_scripts/test.js -watch",
"build:scripts": "esbuild scripts/*.ts --platform=node --format=cjs --outdir=build/_scripts/"
},
...

I added a couple different scripts to my package.json to make it easy, but I discovered that npm, pnpm, and yarn all add about .4 seconds of overhead to any script execution. So if you really want it to be fast then add executable permissions to your built file (chmod +x) and then run it directly in the terminal ./build/_scripts/test.js.

Here’s a typical run completing in less than 1 second, for example.

Step 3: Add live-watching.

1
./build/_script/test.js -watch

Sorry that was a trick, estrella gives us file watching and incremental builds as part of it’s built-in options – so any code changes are picked up and cause a rerun of tests within about 1 second. This is very powerful! Getting instant feedback on your changes is super helpful for speeding up development time and catching errors. It’s just as powerful as linting for improving code cleanliness. Your IDE probably takes almost a second to update the red squigglies on large JS file, so in theory we get feedback about the behavior and functionality of some of our code as quickly as possible! Now of course if your tests are integration tests they are going to be slower while they deal with more layers of the stack, but for simple unit tests this approach should be effective.

View the Source Code for this sample project.