Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
October 24, 2021 07:07 pm GMT

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

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` argumentnode -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# ornpm -i --save-dev tape

and add a script to your project:

{  "scripts": {    "test": "uvu -r solid-register **/*.test.[tj]s **/*.test.[tj]sx"  }}// or{  "scripts": {    "test": "tape -r solid-register **/*.test.[tj]s **/*.test.[tj]sx"  }}

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

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 initnpm 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
\s]
*)(\.\w[\w-_]*)|@keyframes\s+([\{\s\r
]
+?)[\r
\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:

// prerequisitesconst parseHTML = require('linkedom').parseHTML;const emptyHTML = `<!doctype html><html lang="en">  <head><title></title></head>  <body></body></html>`;// create DOMconst {    window,    document,    Node,    HTMLElement,    requestAnimationFrame,    cancelAnimationFrame,    navigator} = parseHTML(emptyHTML);// put DOM into global contextObject.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.


Original Link: https://dev.to/lexlohr/testing-solidjs-code-beyond-jest-39p

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To