Testing Svelte Component Slots

Wooden box with tools inside sitting on a wooden table

In a previous post, I walked you through how to set up Jest for testing Svelte components. That setup can handle basic tests just fine, but testing components that use certain Svelte features—slots for example—can be a little rough.

In this post we’re going to see if we can come up with a way to remove some of the pain from testing slots.

The Problem

Let’s take a look at the simple Button component from the last post:

<button on:click>Click me</button>

To make this component more reusable, we use a slot so that the contents of the button can be changed.

<button on:click><slot /></button>

Unfortunately, svelte-testing-library does not have a way to pass in slot content in tests. A workaround is to make a separate component that uses Button providing slot content, and use that in your test. This is inconvenient because you have to look at a separate file to see what is being tested. You may end up with lots of these test components to handle all your test cases.

In Ember, the ember-cli-htmlbars library gives you the ability to define inline handlebars templates in tests. In React land, you can use JSX directly in your tests. Wouldn’t it be nice if Svelte had something similar?

Our First Attempt

Let’s see if we can use the Svelte compiler in our tests to define components inline.

import { compile } from "svelte/compiler";

function svelte(strings) {
  return eval(
    compile(
      strings.join(""),
      { format: "cjs", dev: true, accessors: true }
    ).js.code
  );
}

That’s not much code! We will be using a tagged template to invoke this function.

The first argument to a tag function is an array of the strings in the template. We’ll join those together and hand them off to the Svelte compiler. We have to use the cjs format, because the esm format will cause errors when it tries to use import. The dev and accessor options are required to work with the render function. This code uses eval, which may raise some alarm bells. It works for us here because this code is only run in tests and is not using input from a user. If ESLint is mad at you, you can use // eslint-disable-next-line no-eval to get it off your back.

test("uses a slot for button contents", async () => {
  render(svelte`
    <script>
      import Button from "./Button.svelte";
    </script>

    <Button>Click me</Button>
  `);

  expect(screen.getByRole("button")).toHaveTextContent("Click me");
});

It works! Nice! In the previous post we wrote a test for a click event. Let’s update that as well:

test("triggers an event when clicked", () => {
  const callback = jest.fn();

  const { component } = render(svelte`
    <script>
      import Button from "./Button.svelte";
    </script>

    <Button on:click />
  `);
  component.$on("click", callback);

  userEvent.click(screen.getByRole("button"));

  expect(callback).toHaveBeenCalled();
});

It works here as well, but I think I am going to get sick of the import boilerplate real quick. Also, having to use .$on to bind event handlers isn’t as nice as putting on:click={callback} directly in the component.

Our component does not have access to the scope where we created it. If it did, we could put the Button import at the top of the file and also have access to the callback variable we defined in the test.

Our Second Attempt

If we could find a way to replace our tagged template with the component code when it’s time to compile, the component would have a closure around the test scope, giving access to imports and local variables. We can use a Babel plugin to do this!

Babel plugins allow you to hook into the compilation process and transform code. The Babel Plugin Handbook is an invaluable resource for learning how to write your own plugins.

Babel plugins use the visitor pattern, where you define functions that are invoked for the various node types in the AST(Abstract Syntax Tree).

ASTs themselves are a topic too large for this blog post, but the Babel Plugin Handbook discusses them. If you want to read a fun book and plunge into the world of parsing, ASTs, byte-code, and programming language design, definitely check out Crafting Interpreters.

To figure out what type of node we want to visit, we can use AST Explorer to visualize the tree. If we paste render(svelte`<Button>Hello</Button>`) in AST Explorer, we get the following AST:

{
  "type": "Program",
  "start": 0,
  "end": 38,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 0,
      "end": 38,
      "expression": {
        "type": "CallExpression",
        "start": 0,
        "end": 38,
        "callee": {
          "type": "Identifier",
          "start": 0,
          "end": 6,
          "name": "render"
        },
        "arguments": [
          {
            "type": "TaggedTemplateExpression",
            "start": 7,
            "end": 37,
            "tag": {
              "type": "Identifier",
              "start": 7,
              "end": 13,
              "name": "svelte"
            },
            "quasi": {
              "type": "TemplateLiteral",
              "start": 13,
              "end": 37,
              "expressions": [],
              "quasis": [
                {
                  "type": "TemplateElement",
                  "start": 14,
                  "end": 36,
                  "value": {
                    "raw": "<Button>Hello</Button>",
                    "cooked": "<Button>Hello</Button>"
                  },
                  "tail": true
                }
              ]
            }
          }
        ],
        "optional": false
      }
    }
  ],
  "sourceType": "module"
}

There is a bit going on there, but we can use AST Explorer’s highlighting to help us figure it out. When we click on the TaggedTemplateExpression in the tree view, we see <Button>Hello</Button> is highlighted. This is exactly what we were looking for! Armed with this info, we can write our plugin:

const compiler = require("svelte/compiler");

module.exports = function () {
  return {
    visitor: {
      TaggedTemplateExpression(path) {
        const tag = path.get("tag");

        if (!tag.isIdentifier({ name: "svelte" })) return;

        const template = path.node.quasi.quasis
          .map((quasi) => quasi.value.cooked)
          .join("");

        const componentSrc = compiler
          .compile(template, {
            format: "cjs",
            accessors: true,
            dev: true,
          })
          .js.code.replace("exports.default =", "return");

        path.replaceWithSourceString(`(function () { ${componentSrc} })()`);
      },
    },
  };
};

Let’s break this down a little bit:

  • First, we skip any tagged templates that don’t have a tag of svelte.
  • Next, get the contents of the template and join it into one string.
  • Then we send it off to the Svelte compiler.
  • Then we do some string manipulations to change the code from a CJS module, to an IIFE (Immediately Invoked Function Expression).
  • Finally, we tell Babel to replace the node with the component code.

The Babel Plugin Handbook was super handy while writing this code. The sections on Writing your first Babel Plugin and Transformation Operations in particular were invaluable.

Now, we need to tell Babel about our plugin. To do this, we’ll add the following our .babelrc:

  "plugins": ["./src/svelte-inline-compile.cjs"]

We can update our test code to look like this:

import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import Button from "./Button.svelte";

test("renders a button", async () => {
  render(svelte`<Button>Click me</Button>`);

  expect(screen.getByRole("button")).toHaveTextContent("Click me");
});

test("triggers an event when clicked", () => {
  const callback = jest.fn();

  render(svelte`<Button on:click={callback} />`);

  userEvent.click(screen.getByRole("button"));

  expect(callback).toHaveBeenCalled();
});

We run the tests and… It works! But now ESLint is complaining about our code. It thinks we have unused and undefined variables in our code.

Making ESLint Happy

Fixing the no-undef warning on our svelte tagged template is easy. We can add the following to the overrides section in our .eslintrc.cjs:

    { files: ["**/*.test.js"], globals: { svelte: "readonly" } }

The no-unused-vars warning on our Button import is going to be a bit more tricky. Because ESLint has no clue what is going on with our svelte tagged templates, it does not know we are using Button in there. Luckily, ESLint has a plugin system!

ESLint Plugin

Much like Babel, ESLint allows you to write plugins using the visitor pattern.

Let’s try writing a plugin that finds our svelte tagged templates and tells ESLint about the variables that are being used.

ESLint plugins need to be npm modules, so we’ll make a new directory in our app’s root called eslint-plugin-svelte-inline-compile.

In this directory, we’ll add the following files:

eslint-plugin-svelte-inline-compile/package.json

{
  "name": "eslint-plugin-svelte-inline-compile",
  "main": "index.js",
  "version": "0.0.1",
  "peerDependencies": {
    "svelte": ">=3.38.3"
  }
}

eslint-plugin-svelte-inline-compile/index.js:

const compiler = require("svelte/compiler");

module.exports = {
  rules: {
    "uses-vars": {
      create(context) {
        return {
          TaggedTemplateExpression(node) {
            if (node.tag.name === "svelte") {
              const template = node.quasi.quasis.map((quasi) => quasi.value.cooked).join("");
              const result = compiler.compile(template);
              result.warnings.forEach(w => {
                if (w.code === "missing-declaration") {
                  const varName = w.message.match("^'([^']+)'")[1];
                  context.markVariableAsUsed(varName);
                }
              });
            }
          }
        };
      }
    }
  },
  configs: {
    recommended: {
      plugins: ["svelte-inline-compile"],
      rules: {
        "svelte-inline-compile/uses-vars": 2
      }
    }
  }
};

We’ll add the following to our package.json’s devDependencies:

"eslint-plugin-svelte-inline-compile": "./eslint-plugin-svelte-inline-compile"

Finally, we’ll add the following to our .eslintrc’s extends:

"plugin:svelte-inline-compile/recommended"

We are now ESLint warning free!

Luckily for us, when Svelte compiles a component, it returns a list of warnings. Among these warnings are any unknown variables to the component. These missing-declarations are values we can assume are being provided by the outside scope of the test. We extract the names from the warning messages and use markVariableAsUsed to let ESLint know we are using these values.

Whac-A-Mole

Is it perfect? Nope, far from it. Unlike JSX in a React test, we don’t get any syntax highlighting and our editor doesn’t provide any code completion. Also, if we used TypeScript, we would have some issues there as well.

Is it at least better than before? I think so! Instead of having to create separate files with components specifically for our tests, we have everything all in one place which makes it easier to see exactly what is being tested.

I’ve created a mono-repo with the Babel and ESLint plugin and made it available on GitHub. I also added a Vite plugin if you are using Vitest instead of Jest. All the code from this post is available on GitHub as well.

Often testing is the last piece to be put in place for a young framework. Hopefully in the future, Svelte will provide a fully baked testing story so the gymnastics above are no longer needed.

Newsletter

Stay in the Know

Get the latest news and insights on Elixir, Phoenix, machine learning, product strategy, and more—delivered straight to your inbox.

Narwin holding a press release sheet while opening the DockYard brand kit box