Testing Solid.js code beyond jest
Alex Lohr
Posted on October 24, 2021
So, you started writing an app or library in Solid.js and TypeScript - what an excellent choice - but now you want to unit test everything as fast as possible to avoid regressions.
We already know how to do this with jest
, but while it is pretty convenient and quite easy to set up, it's also considerably slow and somewhat opinionated. Unlike more lightweight test runners, it also has a built-in code transformation API, a jsdom-based DOM environment and chooses browser
conditional exports by default.
So what we need to run our tests without jest
is:
- Code transformation
- DOM environment
- Choosing
browser
exports
solid-register
To save even more of your precious time, I already did all this work for you. You just need to install
npm i --save-dev solid-register jsdom
and run your test runner with
# test runner that supports the `-r` register argument
$testrunner -r solid-register ...
# test runner without support for the `r` argument
node -r solid-register node_modules/.bin/$testrunner ...
Test runner
You certainly have a lot of options besides jest:
-
uvu
(fastest, but lacks some features) -
tape
(fast, modular, extendable, many forks or extensions like supertape, tabe, tappedout) -
ava
(still fast) -
bron
(tiny, almost no features, fast) -
karma
(a bit slower, but very mature) -
test-turtle
(somewhat slower for a full test, but only runs tests that test files that failed or changed since the last run) -
jasmine
(somewhat full featured test system that jest is partially based on)
and probably a lot more; I couldn't test them all, so I'll focus on uvu
and tape
. Both support the register argument, so all you need to do is to install them
npm -i --save-dev uvu
# or
npm -i --save-dev tape
and add a script to your project:
{
"scripts": {
"test": "uvu -r solid-register"
}
}
// or
{
"scripts": {
"test": "tape -r solid-register"
}
}
Now you can unit test your projects with npm test
.
The following examples are written for
uvu
, but it should be trivial adapting them to tape or any other test runner.
Testing a custom primitive (hook)
Imagine you have a reusable reactive function for Solid.js that doesn't render anything and therefore don't need to use render()
. As an example, let's test a function that returns a number of words or "Lorem ipsum" text:
const loremIpsumWords = 'Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquid ex ea commodi consequat. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint obcaecat cupiditat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'.split(/\s+/);
const createLorem = (words: Accessor<number> | number) => {
return createMemo(() => {
const output = [],
len = typeof words === 'function' ? words() : words;
while (output.length <= len) {
output.push(...loremIpsumWords);
}
return output.slice(0, len).join(' ');
});
};
We need to wrap our test's actions in a reactive root to allow subscription to Accessors like words
. For uvu
, this looks like this (in tape, the assertions are in the first argument that the test
call receives, everything else is pretty similar):
import { createEffect, createRoot, createSignal } from "solid-js";
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
const testLorem = suite('createLorem');
testLorem('it updates the result when words update', async () => {
const input = [3, 2, 5],
expectedOutput = [
'Lorem ipsum dolor',
'Lorem ipsum',
'Lorem ipsum dolor sit amet'
];
const actualOutput = await new Promise<string[]>(resolve => createRoot(dispose => {
const [words, setWords] = createSignal(input.shift() ?? 3);
const lorem = createLorem(words);
const output: string[] = [];
createEffect(() => {
// effects are batched, so the escape condition needs
// to run after the output is complete:
if (input.length === 0) {
dispose();
resolve(output);
}
output.push(lorem());
setWords(input.shift() ?? 0);
});
}));
assert.equal(actualOutput, expectedOutput, 'output differs');
});
testLorem.run();
Testing directives (use:...
)
Next, we want to test the @solid-primitive/fullscreen
primitive, which doubles as directive and exposes something similar to the following API:
export type FullscreenDirective = (
ref: HTMLElement,
active: Accessor<boolean | FullscreenOptions>
) => void;
and is used like this in Solid.js:
const [fs, setFs] = createSignal(false);
return <div use:FullscreenDirective={fs}>...</div>;
You could argue that you want to avoid implementation details and therefore render a component exactly like the one above, but we don't need to render anything, because that would mean us testing the implementation detail of Solid.js' directive interface.
So you can have a look at the test in the solid-primitives
repository.
Testing components
First of all, we need to install solid-testing-library
. Unfortunately, we cannot use @testing-library/jest-dom
here, but the main extensions to jest's expect
are easily replicated.
npm i --save-dev solid-testing-library
We want to test the following simple component:
import { createSignal, Component, JSX } from 'solid-js';
export const MyComponent: Component<JSX.HTMLAttributes<HTMLDivElement>> = (props) => {
const [clicked, setClicked] = createSignal(false);
return <div {...props} role="button" onClick={() => setClicked(true)}>
{clicked() ? 'Test this!' : 'Click me!'}
</div>;
};
Our test now looks like this:
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { screen, render, fireEvent } from 'solid-testing-library';
import { MyComponent } from './my-component';
const isInDom = (node: Node): boolean => !!node.parentNode &&
(node.parentNode === document || isInDom(node.parentNode));
const test = suite('MyComponent');
test('changes text on click', async () => {
await render(() => <MyComponent />);
const component = await screen.findByRole('button', { name: 'Click me!' });
assert.ok(isInDom(component));
fireEvent.click(component);
assert.ok(isInDom(await screen.findByRole('button', { name: 'Test this!' })));
});
Running all three tests in
uvu
with the default settings takes ~1.6s whereas running them injest
using a fastbabel-jest
setup takes ~5.7s on my box.
More missing functionality
Compared to jest
, there's even more functionality missing both in uvu
and tape
:
- simple mocks/spies
- timer mocks
- code coverage collection
- watch mode
- extendable assertions
- snapshot testing
With uvu
, a lot of these functions can be added through external helpers; some are shown in the examples
, e.g. coverage
and watch
and some more not documented there like snoop
to add spies.
For tape
, there is a whole lot of modules.
But remember: functionality that you don't run does not waste your time.
May your tests catch all the bugs!
But how did I do it?
You can safely skip the next part if you are not interested in the details; you should already know enough to test your solid projects with something other than jest. The following code is a reduced version of
solid-register
to mainly show the underlying principles.
Code compilation
Node has an API that allows us to hook into the loading of files require()
'd and register the transpilation code.
We have again three options to do this for us:
- babel-register is using babel to transpile the code; is fast but does not support type checking
- ts-node uses ts-server to transpile the code and provides type safety at the expense of compile time
- We can roll our own solution with babel that allows us to use different presets for different files
babel-register
To use babel-register, we need to install
npm i --save-dev @babel/core @babel/register \
@babel/preset-env @babel/preset-typescript \
babel-preset-solid
Now we have to use it inside our compilation-babel.ts
to combine it with the options required to compile our solid files:
require('@babel/register')({
"presets": [
"@babel/preset-env",
"babel-preset-solid",
"@babel/preset-typescript"
],
extensions: ['.jsx', '.tsx', '.ts', '.mjs']
});
ts-node
While the main point of this package is to provide an interactive typescript console, you can also use it to run typescript directly in node. We can install it like this:
npm i --save-dev ts-jest babel-preset-solid @babel/preset-env
Once installed, we can use it in our compilation-ts-node.ts
:
require('ts-node').register({ babelConfig: {
presets: ['babel-preset-solid', '@babel/preset-env']
} });
Our own solution
Why would we want our own solution? Both babel-register
and ts-jest
only allow us to set up a single set of presets to compile the modules, which means that some presets may run in vain (e.g. typescript compilation for .js files). Also, this allows us to handle files not taken care of by these solutions (see Bonus chapters).
As a preparation, we create our solid-register
directory and in it, init our repo and install our requirements:
npm init
npm i --save-dev @babel/core @babel/preset-env \
@babel/preset-typescript babel-preset-solid \
typescript @types/node
How do babel-register
and ts-jest
automatically compile imports? They use the (unfortunately deprecated and woefully underdocumented, but still workable) require.extensions API to inject itself into the module loading process of node.
The API is rather simple:
// pseudo code to explain the API,
// it's a bit more complex in reality:
require.extensions[extension: string = '.js'] =
(module: module, filename: string) => {
const content = readFromCache(module)
?? fs.readFileSync(filename, 'UTF-8');
module._compile(content, filename);
};
In order to simplify wrapping it, we create our own src/register-extension.ts
with the following method that we can reuse later:
export const registerExtension = (
extension: string | string[],
compile: (code: string, filename: string) => string
) => {
if (Array.isArray(extension)) {
extension.forEach(ext => registerExtension(ext, compile));
} else {
const modLoad = require.extensions[extension] ?? require.extensions['.js'];
require.extensions[extension] = (module: NodeJS.Module, filename: string) => {
const mod = module as NodeJS.Module & { _compile: (code) => void };
const modCompile = mod._compile.bind(mod);
mod._compile = (code) => modCompile(compile(code, filename));
modLoad(mod, filename);
}
}
};
Now we can start compiling our solid code by creating the file src/compile-solid.ts
containing:
const { transformSync } = require('@babel/core');
const presetEnv = require('@babel/preset-env');
const presetSolid = require('babel-preset-solid');
const presetTypeScript = require('@babel/preset-typescript');
import { registerExtension } from "./register-extension";
registerExtension('.jsx', (code, filename) =>
transformSync(code, { filename, presets: [presetEnv, presetSolid] }));
registerExtension('.ts', (code, filename) =>
transformSync(code, { filename, presets: [presetEnv, presetTypeScript] }));
registerExtension('.tsx', (code, filename) =>
transformSync(code, { filename, presets: [presetEnv, presetSolid, presetTypeScript] }));
Bonus #1: Filename aliases
If we don't want to use the --conditions
flag to choose the browser version, we can also use aliases for certain filenames to force node to choose the browser exports from solid. To do so, we create src/compile-aliases.ts
;
const aliases = {
'solid-js\/dist\/server': 'solid-js/dist/dev',
'solid-js\/web\/dist\/server': 'solid-js/web/dist/dev'
// add your own here
};
const alias_regexes = Object.keys(aliases)
.reduce((regexes, match) => {
regexes[match] = new RegExp(match);
return regexes;
}, {});
const filenameAliasing = (filename) =>
Object.entries(aliases).reduce(
(name, [match, replace]) =>
!name && alias_regexes[match].test(filename)
? filename.replace(alias_regexes[match], replace)
: name,
null) ?? filename;
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
extensions.forEach(ext => {
const loadMod = require.extensions[ext] ?? require.extensions['.js'];
require.extensions[ext] = (module: NodeJS.Module, filename: string) => {
loadMod(module, filenameAliasing(filename));
};
});
Bonus #2: CSS loader
When we import "file.css", we usually tell our build system to load the css code into the current page using its internal loader and if it is a CSS module, provide the class names in the import.
By providing our own loader for '.css'
and '.module.css'
, we can have the same experience in node and allow our DOM to actually access the styles.
So we write the following code in our own src/compile-css.ts
:
import { registerExtension } from "./register-extension";
const loadStyles = (filename: string, styles: string) =>
`if (!document.querySelector(\`[data-filename="${filename}"]\`)) {
const div = document.createElement('div');
div.innerHTML = \`<style data-filename="${filename}">${styles}</style>\`;
document.head.appendChild(div.firstChild);
styles.replace(/@import (["'])(.*?)\1/g, (_, __, requiredFile) => {
try {
require(requiredFile);
} catch(e) {
console.warn(\`attempt to @import css \${requiredFile}\` failed); }
}
});
}`;
const toCamelCase = (name: string): string =>
name.replace(/[-_]+(\w)/g, (_, char) => char.toUpperCase());
const getModuleClasses = (styles): Record<string, string> => {
const identifiers: Record<string, string> = {};
styles.replace(
/(?:^|}[\r\n\s]*)(\.\w[\w-_]*)|@keyframes\s+([\{\s\r\n]+?)[\r\n\s]*\{/g,
(_, classname, animation) => {
if (classname) {
identifiers[classname] = identifiers[toCamelCase(classname)] = classname;
}
if (animation) {
identifiers[animation] = identifiers[toCamelCase(animation)] = animation;
}
}
);
return identifiers;
};
registerExtension('.css', (styles, filename) => loadStyles(filename, styles));
registerExtension('.module.css', (styles, filename) =>
`${loadStyles(filename, styles)}
module.exports = ${JSON.stringify(getModuleClasses(styles))};`);
Bonus #3: asset loader
The vite server from the solidjs/templates/ts
starter allows us to get the paths from asset imports. By now, you should now the drill and you could probably write src/compile-assets.ts
yourself:
import { registerExtension } from "./register-extension";
const assetExtensions = ['.svg', '.png', '.gif', '.jpg', '.jpeg'];
registerExtension(assetExtensions, (_, filename) =>
`module.exports = "./assets/${filename.replace(/.*\//, '')}";`
);
There is also support for ?raw
paths in vite. If you want, you can extend this part, to support them; the current version of solid-register
at the time of writing this article has no support for it yet.
DOM environment
As for the compilation, we do have different options for the DOM environment:
- jsdom, full-featured, but slow, the default option in jest
- happy-dom, more lightweight
- linkedom, fastest, but lacks essential features
Unfortunately, happy-dom
is currently not fully tested and linkedom
will not really work with solid-testing-library
, so using them is discouraged at the moment.
jsdom
Since jsdom is basically meant to be used like this, registering it is simple:
import { JSDOM } from 'jsdom';
const { window } = new JSDOM(
'<!doctype html><html><head></head><body></body></html>',
{ url: 'https://localhost:3000' }
);
Object.assign(globalThis, window);
happy-dom
import { Window } from 'happy-dom';
const window = new Window();
window.location.href = 'https://localhost:3000';
for (const key of Object.keys(window)) {
if ((globalThis as any)[key] === undefined && key !== 'undefined') {
(globalThis as any)[key] = (window as any)[key];
}
}
linkedom
To create our DOM environment, the following will suffice:
// prerequisites
const parseHTML = require('linkedom').parseHTML;
const emptyHTML = `<!doctype html>
<html lang="en">
<head><title></title></head>
<body></body>
</html>`;
// create DOM
const {
window,
document,
Node,
HTMLElement,
requestAnimationFrame,
cancelAnimationFrame,
navigator
} = parseHTML(emptyHTML);
// put DOM into global context
Object.assign(globalThis, {
window,
document,
Node,
HTMLElement,
requestAnimationFrame,
cancelAnimationFrame,
navigator
});
Lastly, you can put all this together with some config reading function like I did. If you ever have to create a similar package for your own custom transpiled framework, I hope you'll stumble over this article and it'll help you.
Thanks for your patience, I hope I didn't wear it out too much.
Posted on October 24, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.