DEV Community

Cover image for Testing Solid.js code beyond jest
Alex Lohr
Alex Lohr

Posted on • Updated on

Testing Solid.js code beyond jest

Solid.js logo

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:

  1. Code transformation
  2. DOM environment
  3. 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
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and add a script to your project:

{
  "scripts": {
    "test": "uvu -r solid-register"
  }
}
// or
{
  "scripts": {
    "test": "tape -r solid-register"
  }
}
Enter fullscreen mode Exit fullscreen mode

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(' ');
  });
};
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

and is used like this in Solid.js:

const [fs, setFs] = createSignal(false);
return <div use:FullscreenDirective={fs}>...</div>;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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!' })));
});
Enter fullscreen mode Exit fullscreen mode

Running all three tests in uvu with the default settings takes ~1.6s whereas running them in jest using a fast babel-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. coverageand 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:

  1. babel-register is using babel to transpile the code; is fast but does not support type checking
  2. ts-node uses ts-server to transpile the code and provides type safety at the expense of compile time
  3. 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
Enter fullscreen mode Exit fullscreen mode

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']
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Once installed, we can use it in our compilation-ts-node.ts:

require('ts-node').register({ babelConfig: {
  presets: ['babel-preset-solid', '@babel/preset-env']
} });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  };
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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] }));
Enter fullscreen mode Exit fullscreen mode

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));
  };
});
Enter fullscreen mode Exit fullscreen mode

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))};`);
Enter fullscreen mode Exit fullscreen mode

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(/.*\//, '')}";`
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (1)

Collapse
tarikoez profile image
TarikOez

Just as informative as the article Solid+Jest from you. 👍