Skip to content

Unit Testing Qwik Components

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

Unit Testing Qwik Components

Qwik is a new, superfast JavaScript framework from builder.io. Created by Miško Hevery, the author of AngularJS, Qwik aims to deliver instant loading web applications of any size or complexity through resumability. This is accomplished partially by delaying the execution and download of JavaScript for as long as possible while providing an excellent developer experience.

"You know React? You know Qwik."

qwik.builder.io

Given Miško's background as the creator of AngularJS, it's interesting that writing Qwik feels very similar to writing React. It even has a React compatibility mode. Personally, I have experience using mostly Angular, but I found that developing with Qwik was very smooth and enjoyable.

So, although I am definitely hesitant to jump on new technologies, after attending Miško's workshop, browsing through all the resources at framework.dev tinkering with Qwik, and building a couple of apps with it, I became a fan.

However, being a responsible software developer, I then wanted to add unit tests to my Qwik app. And because Qwik is relatively new, I found out there was little documentation on how to do this. Furthermore, until recently, there were no tools to easily set up and interact with Qwik components in unit tests. But that didn't stop me. I explored, asked, and waited for a PR to be merged, and now I am proud to present a comprehensive guide to unit testing Qwik apps. So, without further ado, let's dive into setting up our testing environment.

Configuring a Testing Environment

There are several options when it comes to choosing a testing framework, and I have even experimented with using Jest, as it is the popular choice for unit testing. However, since Qwik uses Vite, I found that using Vitest was the most straightforward and efficient option. Vitest is specifically designed to be the go-to test runner for Vite projects, which makes it a great fit.

If you are familiar with Jest, you will be happy to know that the Vitest API is very similar, so you won't have to learn much to use it effectively.

Before moving further to the actual setup, let's make sure you have a Qwik project to test. If you don't yet, you can simply create one by running npm create qwik@latest.

Now, let's set up vitest in our Qwik project. It takes 3 easy steps:

  1. Install Vitest as a dev dependency by running npm i vitest --save-dev from the root of your project. This will update the devDependencies array in your package.json file.

  2. Update your vite.config.ts file to include a test configuration in the defineConfig function. You can start with an empty object for now, but you can always add configuration options later on. Your vite.config.ts should look something like this:

// ./vite.config.ts
import { defineConfig } from 'vite';
import { qwikVite } from '@builder.io/qwik/optimizer';
import { qwikCity } from '@builder.io/qwik-city/vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig(() => {
  return {
    plugins: [qwikCity(), qwikVite(), tsconfigPaths()],
    preview: {
      headers: {
        'Cache-Control': 'public, max-age=600',
      },
    },
    test: {}, // this is the config entry we are adding
  };
});
  1. Update the "scripts" section of your package.json file to include commands for running vitest once, running it in watch mode, and for generating coverage reports. Your "scripts" section should look something like this:
"scripts": {
    ...
    "test": "vitest --run",
    "test.watch": "vitest",
    "coverage": "vitest run --coverage"
}

After completing these steps, you should be able to run npm run test and npm run coverage from your terminal to use Vitest.

Note that if you run npm run test and you don't have any tests yet, which is likely the case at this point, you will see the message No test files found, exiting with code 1. Don't worry. We are about to add some tests shortly.

Writing Tests

By default, vitest will look for files with names that match the pattern '**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}. In a Qwik project that uses TypeScript and returns JSX elements, it makes sense to use the *.spec.tsx or *.test.tsx pattern. I prefer to use the.spec suffix. But .test works just as well, and is equally valid. Feel free to choose the one that you like best.

To proceed with writing a unit test, we will need a component to test. For this example, we will use a simple counter component that has a div that displays a number and a button that increments it. To create the component, follow these steps:

  1. Go to the src/components directory, and create a new folder called counter.

  2. Inside the counter folder, create a new file called counter.tsx and add the following code:

import { component$, useStore } from "@builder.io/qwik";

export const Counter = component$(() => {
  const state = useStore({
    count: 0,
  });

  return (
    <>
      <button class="increment" onClick$={() => state.count++}>
        +
      </button>
      <div class="count">{state.count}</div>
    </>
  );
});
  1. Next to the counter.tsx file, create a new file called counter.spec.tsx file next to it. This file will contain the unit tests for the Counter component.

Now that we have our component and our test file, we can proceed to write our unit tests. Let's open counter.spec.tsx and add some boilerplate along with a simple test to verify we have everything set up correctly:

import { describe, expect, it } from "vitest";
import { Counter } from "./counter";

describe("Counter component", function () {
  it('should assert true', async () => {
    // this should always pass
    expect(true).toBe(true);
  });
});

We have a describe method that groups related tests together (in our case, we're grouping all the tests we will write for our Counter component), an expect method which is used to make assertions about the values returned by the code being tested, and an it method that defines an individual test.

If we now run npm run test, we should see the following output:

 Test Files  1 passed (1)
      Tests  1 passed (1)

Writing a Simple Test Case

Now that we verified that our dummy test work, let's actually test our component's behavior. First, we want to test that it renders correctly, so we will remove our dummy test and leverage the new createDOM method from @builder.io/qwik.testing to render our Counter component inside the test:

import { createDOM } from "@builder.io/qwik/testing"; // import the createDOM method
import { describe, expect, it } from "vitest";
import { Counter } from "./counter";

describe("Counter component", function () {
  it("should render", async () => {
    // create the component's DOM and get back the container and a render method
    const { screen, render } = await createDOM();

    // call the render method with the JSX node of our Counter component as a parameter
    await render(<Counter />);

    // get the div that displays the count from our container
    const countElement = screen.querySelector(".count");

    // assert the displayed count is "0" which is the default value
    expect(countElement?.textContent).toBe("0");
  });
});

Rerunning the test should result in a pass if we did everything correctly. Now we know our component does render and displays zero as the count.

Testing Interactions

"Great," you say, "but I want to test the component's logic, not just the initial render!". Well, you're in luck because the createDOM method returns one more property - userEvent - that you can use to interact with the component's DOM. This allows us to simulate user interactions, such as clicking a button, and test whether the component responds correctly.

For example, we can test whether our counter correctly increments the count by clicking the button. Here is the updated code example:

  it("should increment on click", async () => {
    // we retrieve the `userEvent` method along with `screen` and `render`
    const { screen, render, userEvent } = await createDOM();

    // render the component
    await render(<Counter />);

    // get the div that displays the count from our container
    const countElement = screen.querySelector(".count");

    // assert the displayed count is "0" which is the default value
    expect(countElement?.textContent).toBe("0");

    // pass a selector that matches the increment button as a first parameter 
    // and the name of the event we want to trigger ("click") as the second parameter
    await userEvent("button.increment", "click");

    // assert the displayed count is now incremented from 0 to 1 
    expect(countElement?.textContent).toBe("1");
  });

With this additional test, we can be confident that our Counter component not only renders correctly, but also updates the count as expected when the increment button is clicked. Voilà, our component's behavior has been verified!

Mocking Qwik Hooks

So far, we have a test that verifies the component's logic, which is great! However, there may come a time when we need to mock parts of the component's logic to test certain scenarios. Unfortunately, here comes a little trade-off for the sake of Qwik's quickness. The component$ wrapper does not expose the internals of the component, so we cannot easily modify it for our tests.

Fortunately, the most common thing we need to mock in Qwik components are hooks such as useLocation() or useStore(). We can do this by using vi.mock. As we can read in the Vitest docs, the vi.mock method takes two arguments: the path to the module that we want to mock, and a function that returns the mocked module.

For example, if we want to modify the initial count in our useStore() hook to be 1, we can mock the entire @builder.io/qwik module and return the actual module with the modified initial value. We can use the JavaScript bind method to do this. The call to vi.mock can be placed anywhere in the code as it is hoisted and will always be called before modules are imported, but for clarity, we will put it in a beforeAll block:

import { createDOM } from "@builder.io/qwik/testing";
import { describe, expect, it, vi, beforeAll } from "vitest";
import { Counter } from "./counter";

beforeAll(() => {
  // mock useStore to start with count of 1 instead of 0
  vi.mock("@builder.io/qwik", async () => {
    const qwik = await vi.importActual<typeof import("@builder.io/qwik")>(
      "@builder.io/qwik"
    );
    return {
      ...qwik, // return most of the module unchanged
      // leverage bind to set the initial state of useStore
      useStore: qwik.useStore.bind("initialState", { count: 1 }),
    };
  });
});

describe("Counter component", function () {
  it("should increment on click", async () => {
    // we retrieve the `userEvent` method along with `screen` and `render`
    const { screen, render, userEvent } = await createDOM();

    // render the component
    await render(<Counter />);

    // get the div that displays the count from our container
    const countElement = screen.querySelector(".count");

    // assert the displayed count is "1" - the default value set by our mock
    expect(countElement?.textContent).toBe("1");

    // pass a selector that matches the increment button as a first parameter 
    // and the name of the event we want to trigger ("click") as the second parameter
    await userEvent("button.increment", "click");

    // assert the displayed count is now incremented from 1 to 2
    expect(countElement?.textContent).toBe("2");
  });
});

Mocking the useLocation hook is even simpler, as it is not expected to change during a test. In this case, we can directly return an object from the mock. Here is an example of how to mock the useLocation hook:

vi.mock("@builder.io/qwik", async () => {
  const qwik = await vi.importActual<typeof import("@builder.io/qwik")>(
    "@builder.io/qwik"
  );
  return {
    ...qwik,
    // return a hardcoded object for every useLocation call
    useLocation: {
      params: {},
      href: "/mock",
      pathname: "mock",
      query: {},
    }
  };
});

Conclusion

As a newcomer to the world of JavaScript frameworks, Qwik offers a unique approach to building fast, efficient web applications. However, we shouldn't forget about the quality and robustness of our code. In this blog post, we provided a comprehensive guide on how to set up and write tests for your Qwik components.

Setting up a testing environment for a Qwik app is relatively straightforward. Qwik uses Vite as its build tool, which means that the most efficient option for unit testing is to use Vitest, a test runner specifically designed for Vite projects. We showed how to install Vitest and update the Vite config file to include a test configuration, and went through examples of how to write tests for Qwik components, including how to mock hooks and test interactions with the DOM.

By following the steps and examples in this post, you can easily add unit tests to your Qwik app, and ensure that your components are working correctly. This will give you confidence in your code, and allow you to build more complex and robust applications with Qwik. And if you found this article a bit overwhelming, do not worry, you can check out the Qwik starter kit which includes examples of already implemented tests.

Have fun writing Qwik and reliable apps!

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co