Testing Solid.js code beyond jest
Tags: solidjs, testing, javascript, typescript
Published: 2021-10-24 19:07:23

![Solid.js logo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fla7r78gx3kdw15cburq.png)

So, you started writing an app or library in [Solid.js](https://www.solidjs.com/) 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`](https://dev.to/lexlohr/testing-your-solidjs-code-2gfh), 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

```sh
npm i --save-dev solid-register jsdom
```

and run your test runner with

```sh
# 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

```sh
npm -i --save-dev uvu
# or
npm -i --save-dev tape
```

and add a script to your project:

```js
{
  "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:

```ts
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):

```ts
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:

```ts
export type FullscreenDirective = (
  ref: HTMLElement,
  active: Accessor<boolean | FullscreenOptions>
) => void;
```

and is used like this in Solid.js:

```ts
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](https://github.com/davedbase/solid-primitives/blob/3e82ca6d5f315c698bda98ed58d4bf04ef343368/packages/fullscreen/test/index.test.ts) 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.

```sh
npm i --save-dev solid-testing-library
```

We want to test the following simple component:

```ts
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:

```ts
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 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`](https://github.com/lukeed/uvu/tree/master/examples), e.g. [`coverage`](https://github.com/lukeed/uvu/tree/master/examples/coverage)and [`watch`](https://github.com/lukeed/uvu/tree/master/examples/watch) and some more not documented there like [`snoop`](https://github.com/therealparmesh/snoop) to add spies.

For `tape`, there is a [whole lot of modules](https://github.com/substack/tape#things-that-go-well-with-tape).

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](https://babeljs.io/docs/en/babel-register) is using babel to transpile the code; is fast but does not support type checking
2. [ts-node](https://typestrong.org/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

```sh
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:

```ts
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:

```sh
npm i --save-dev ts-jest babel-preset-solid @babel/preset-env
```

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

```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:

```sh
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](https://nodejs.org/api/modules.html#modules_require_extensions) to inject itself into the module loading process of node.

The API is rather simple:

```ts
// 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:

```ts
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:

```ts
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`;

```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`:

```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](https://vitejs.dev/) 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:

```ts
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](https://github.com/jsdom/jsdom), full-featured, but slow, the default option in jest
- [happy-dom](https://github.com/capricorn86/happy-dom), more lightweight
- [linkedom](https://github.com/WebReflection/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:

```ts
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

```ts
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:

```ts
// 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.