DEV Community

Cover image for Testing your Solid.js code with jest
Alex Lohr
Alex Lohr

Posted on • Edited on

Testing your Solid.js code with jest

Solid.js logo

So, you have started to write a nice app or library in Solid.js and TypeScript - good choice, by the way - but now you want to unit test everything to make sure nothing breaks.

Jest logo

Jest is currently one of the best options for front end testing, but it requires some setup to play nicely with Solid.js. There are two options:

  • solid-jest - a preset to setup jest transpile solid using babel, given a working babel configuration; won't type-check, but is faster
  • ts-jest - a module to use TypeScript with jest that needs to be coerced a bit to work with Solid.js

If you are using TypeScript, choosing solid-jest might save you time when running the tests at the expense of missing type checks, but those can be run separately. ts-jest will check the types for you, but will take a bit longer. Both choices are valid, so decide for yourself.

Configuration

Regardless if you use solid-jest or ts-jest, you will need a babel config that supports Solid.js - the main difference is where to put it. It looks like this:



{
  "presets": [
    "@babel/preset-env",
    "babel-preset-solid",
    // only if you use TS with solid-jest
    "@babel/preset-typescript"
  ]
}


Enter fullscreen mode Exit fullscreen mode

To be able to run it, you need to add the dev dependencies @babel/core, @babel/preset-env and optionally @babel/preset-typescript depending on if you use TypeScript or not.

If you decided to use solid-jest, then put the babel config in your .babelrc file at the project root; otherwise it goes into the jest config section e.g. in package.json/jest as shown in the upcoming ts-jest section.

solid-jest

For solid-jest, the jest config that requires .babelrc to run looks like this (without the comment):



{
  "jest" : {
    "preset": "solid-jest/preset/browser",
    // insert setupFiles and other config
  }
}


Enter fullscreen mode Exit fullscreen mode

ts-jest

If you use ts-jest instead, it looks like this (without the comments):



{ 
  "jest": {
    "preset": "ts-jest",
    "globals": {
      "ts-jest": {
        "tsconfig": "tsconfig.json",
        "babelConfig": {
          "presets": [
            "babel-preset-solid",
            "@babel/preset-env"
          ]
        }
      }
    },
    // insert setupFiles and other config
    // you probably want to test in browser mode:
    "testEnvironment": "jsdom",
    // unfortunately, solid cannot detect browser mode here,
    // so we need to manually point it to the right versions:
    "moduleNameMapper": {
      "solid-js/web": "<rootDir>/node_modules/solid-js/web/dist/web.cjs",
      "solid-js": "<rootDir>/node_modules/solid-js/dist/solid.cjs"
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

What now?

I assume you already know your way around jest. If not, let me point you at this article series about jest. So why is here more text, you ask? There are a few more considerations when testing Solid.js code:

  • effects and memos only work inside a reactive root
  • components output HTMLElements or functions that returns them (either a single one or an array).

That means you can use a few shortcuts instead of actual rendering everything and look at it from a DOM perspective. Let's look at the available shortcuts:

Testing custom primitives ("hooks")

A powerful way to make functionality reusable is to put it into a separate primitive function. Let's have a naive implementation of a function that returns a variable number of words of "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

If we use words as an Accessor, it will only ever be updated inside a reactive root and the updates only available inside an effect. Luckily, jest will happily wait for async functions, so we can use a promise to collect the output that will finally be evaluated.



test(
  '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);
      });
    }));

    expect(actualOutput).toEqual(expectedOutput);
  }
);


Enter fullscreen mode Exit fullscreen mode

This way, we don't even need to render the output and are very flexible with the test cases.

Testing directives (use:...)

A custom directive is merely a primitive that receives a DOM reference and an Accessor with arguments, if there are any. Let's consider a directive that abstracts the Fullscreen API. It would have the following signature:



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


Enter fullscreen mode Exit fullscreen mode

and is used like this:



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


Enter fullscreen mode Exit fullscreen mode

Do we need to render now to test the directive? No, we don't! We can just create our HTMLElement by using document.createElement('div') and test it like our previous primitive.

While you could argue that you want to test things from the user's perspective, the user of a directive is still a developer that uses it in the component - and you don't need to test if Solid.js actually works, that's already done for you by the maintainers.

You can have a look at an actual example at the fullscreen primitive from solid-primitives written by yours truly.

Testing components

Finally, we get to use render!? No? Don't tell me you're testing the returned DOM elements without ever rendering them!?

Well, you certainly could do that, but not even I'm suggesting you should, because components are usually used in a DOM context and not solely in a component context, as rendering could introduce side effects. Instead, I want to point you at Solid's testing library.

So let's add solid-testing-library to our project, together with @testing-library/jest-dom and configure it:



// in package.json
{
  // ...
  "jest": {
    "preset": "solid-jest/preset/browser",
    "setupFilesAfterEnv": ["./src/setupTests.ts"]
  }
}

// and in ./src/setupTests.ts:
import "regenerator-runtime/runtime";
import '@testing-library/jest-dom'


Enter fullscreen mode Exit fullscreen mode

Our project contains the following nonsensical component that we want be tested:



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

Now let's write a simple test using the testing library:



import { screen, render, fireEvent } from 'solid-testing-library';
import { MyComponent } from './my-component';

test('changes text on click', async () => {
  await render(() => <MyComponent />);
  const component = await screen.findByRole('button', { name: 'Click me!' });
  expect(component).toBeInTheDocument();
  fireEvent.click(component);
  expect(await screen.findByRole('button', { name: 'Test this!' })).toBeInTheDocument();
});


Enter fullscreen mode Exit fullscreen mode

Let's run this:



> jest

 PASS  src/testing.test.tsx
  ✓ changes text on click (53 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        4.012 s
Ran all test suites.


Enter fullscreen mode Exit fullscreen mode

May your tests catch all the bugs!

Top comments (7)

Collapse
 
peerreynders profile image
peerreynders • Edited

Thank You.

To wrap my head around createRoot I needed something much more basic.

// $ node index.mjs
//
// NOTE: in Node.js "solid-js" maps to 
// "./node_modules/solid-js/dist/server.js"
// which doesn't run Effects
//
import {
  createEffect,
  createSignal,
  createRoot,
} from './node_modules/solid-js/dist/solid.js';

const log = console.log;

function executor(resolve, _reject) {
  createRoot(withRxGraph);

  // ---
  function withRxGraph(dispose) {
    const [count, setCount] = createSignal(0);

    let result;
    createEffect(() => {
      log('running effect');
      result = count();
    });

    setTimeout(() => {
      log('incrementing');
      setCount((n) => n + 1);

      log('flushing');
      dispose();

      resolve(result);
    });

    log('end setup');
  }
}

new Promise(executor).then((n) => log('result', n));
Enter fullscreen mode Exit fullscreen mode

Leaving it here in case somebody else might find it useful.

// $ node index.mjs
//
// NOTE: in Node.js "solid-js" maps to "./node_modules/solid-js/dist/server.js"
// which doesn't run Effects
//
import {
  createEffect,
  createSignal,
  createRoot,
} from './node_modules/solid-js/dist/solid.js';

const log = console.log;

async function rootAndRun(timeoutMs, factory) {
  let disposeFn;
  let timeoutId;
  try {
    return await new Promise(executor);
  } finally {
    if (disposeFn) {
      log('dispose');
      disposeFn();
      disposeFn = undefined;
    }
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
    }
  }

  // ---
  function executor(resolve, reject) {
    createRoot((dispose) => {
      disposeFn = dispose;
      timeoutId = setTimeout(timeout, timeoutMs);
      // queueMicrotask/setTimeout allows `setup` to finish
      // before exercising the reactive graph with `run`
      const run = factory(done);
      if (typeof run === 'function') queueMicrotask(run);
    });

    // ---
    function timeout() {
      timeoutId = undefined;
      reject(new Error('Timed out'));
    }

    function done(data, err) {
      log('done');
      if (err != undefined) reject(err);
      else resolve(data);
    }
  }
}

function factory(done) {
  // `setup` immediately
  const [count, setCount] = createSignal(0);

  let result;
  createEffect(() => {
    log(typeof result === 'number' ? 'effect' : 'first effect');
    result = count();
  });

  log('end setup');

  // package `run` in a function
  return function run() {
    // now effects are synchronous
    setCount((n) => n + 1);
    log('past increment');
    done(result);
  };
}

try {
  const result = await rootAndRun(1000, factory);
  console.log(result);
} catch (err) {
  console.error('ERROR', err);
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
romaintrotard profile image
Romain Trotard

Really useful. Thank you for the config :)

Collapse
 
lexlohr profile image
Alex Lohr • Edited

You may want to have a look at vitest, which can easily replace jest while being smaller and faster.

There's now an official starter template with typescript, vitest, solid-testing-library and jest-dom included that can serve as an example.

Collapse
 
romaintrotard profile image
Romain Trotard

Gonna give it a try :)

Collapse
 
tarikoez profile image
TarikOez • Edited

Wow that was really informative, thank you! Will definitely use the stack for my next project

Collapse
 
lexlohr profile image
Alex Lohr

If you like me want to go beyond jest, my next post is for you: dev.to/lexlohr/testing-solidjs-cod...

Collapse
 
lexlohr profile image
Alex Lohr

And if you don't want to miss out on jest-like APIs, vitest has your back: dev.to/lexlohr/testing-your-solidj...