✍️Testing in Storybook
Tags: webdev, testing, frontend
Published: 2024-04-18 07:12:55

## Introduction

Storybook provides an environment where you can build components in isolation, and checking edge case UI states became easier with Storybook. What's more, you can write tests in Storybook. Also, testing environment comes with zero configuration. Aren't you excited? In this post, I will talk about what made me start testing in Storybook, how you can set up testing in Storybook, some issues I had with Storybook Addons.

## Motivation to do testing in Storybook

### `jsdom` in Jest cannot mock real DOM fully

[React Testing Library](https://testing-library.com/docs/) has become a go-to option for testing React applications since you can write tests from a user perspective. Here is its core principle in their official docs.

> The more your tests resemble the way your software is used, the more confidence they can give you.

So I tried Jest/React-Testing-Libary and was quite satisfied with these technologies. However, I got stuck when I tried to test `Dialog` element. It turns out there are [some known limitations with jsdom](https://github.com/jsdom/jsdom/issues/3294). Since `jsdom` is not real DOM, I came across a situation in which I can't test the element in the way it is used by users.

### Finding Alternatives

#### Another javascript-implemented DOM
- [happydom](https://github.com/capricorn86/happy-dom): It's another javascript implementation of DOM. However, its community is way smaller than `jsdom`. The repository has 2.9k+ stars, so I can't make sure that I would get a huge community support.

#### Using real DOM
- real DOM: `jsdom` lets us see the result of component testing immediately in the local development environment whenever our codebase changes. That's one of the important part of automated testing. Once we start using real DOM, it's clear that the execution time of testing will be too slow.

### Innovative Solution
- When you develop in local development, you typically run `yarn storybook` and see the result. Since Storybook already renders stories(components) in real DOM, it can reuse the rendered components to run component testing. [According to Storybook's Benchmark, Storybook interaction testing is 30% slower than jest/react-testing-library and sometimes it is even faster](https://github.com/storybookjs/storybook/discussions/16861#discussioncomment-2513340). Internally, Storybook uses jest/playwright to run the tests.

- In addition, it becomes easier to track down bugs since you can see the interaction flow visually in Storybook, rather than seeing the dumped HTML when the test fails. Debugging is made easier.

- Storybook's testing methods are similar to those of Jest/React-Testing-Library, so it was clear that I would get used to it easily.


## How to set up

### Test Runner

1. Install test runner

    ```
yarn add --dev @storybook/test-runner
    ```


2. Run the test-runner

    ```
    yarn test-storybook
    ```

### Interaction Testing

1. Add this to config of your ./storybook/main.ts

    ```ts
    const config: StorybookConfig = {
      addons: [
        '@storybook/addon-interactions',
        ...,
      ],
    }
    ```

2. Write an interaction test.

    ```ts
    // More on interaction testing: 
    // https://storybook.js.org/docs/writing-tests/interaction-testing
    export const LoggedIn: Story = {
      play: async ({ canvasElement }) => {
        const canvas = within(canvasElement);
        const loginButton = canvas.getByRole("button", { name: /Log in/i });
        await expect(loginButton).toBeInTheDocument();
        await userEvent.click(loginButton);
        await expect(loginButton).not.toBeInTheDocument();

        const logoutButton = canvas.getByRole("button", { name: /Log out/i });
        await expect(logoutButton).toBeInTheDocument();
      },
    };
    ```

  - `play`: this function runs after the story finishes rendering.
  - `click`: Storybook lets you use `user-events` in the same way as Reac Testing Library.
  - `expect`: assertion function


### Test Coverage
Test coverage shows any code lines that tests haven't gone through.

1. Install the addon.

    ```
    yarn add --dev @storybook/addon-coverage
    ```

2. Include the addon in main.ts

    ```ts
    const config: StorybookConfig = {
      addons: [
        '@storybook/addon-coverage', 
        ...,
      ],
    };
    ```

3. Run the test runner with `--coverage option`.

    ```
    yarn test-storybook --coverage
    ```

### End-to-end Testing
You can navigate to the URL of storybook and do end-to-end testing straight up.

```ts
import { Frame } from "@playwright/test";
import { test, expect } from "./test";

let frame: Frame;

test.beforeEach(async ({ page }) => {
  await page.goto(
    "http://localhost:6006/?path=/story/example-page--logged-out"
  );
  await expect(page.getByTitle("storybook-preview-iframe")).toBeVisible();
  frame = page.frame({ url: /http:\/\/localhost:6006\/iframe.html/ })!;
  await expect(frame).not.toBeNull();
});

test("has logout button", async ({ page }) => {
  const loginButton = frame.getByRole("button", { name: /John/i }).first();
  await expect(loginButton).toBeVisible();
  await loginButton.click();

  await expect(
    frame.getByRole("button", {
      name: /John/i,
    })
  ).toBeVisible();
});
```

### API Mocking

  1. Install the addon.

    ```
yarn add msw msw-storybook-addon --dev
    ```

  2. Generate service worker to your `public` directory.

    ```
yarn msw init public/
    ```

  3. Include the addon in .storybook/preview.ts

    ```ts
import { initialize, mswLoader } from 'msw-storybook-addon'
// Initialize MSW
initialize()
const preview = {
  parameters: {
    // your other code...
  },
  // Provide the MSW addon loader globally
  loaders: [mswLoader],
}
export default preview
    ```

  4. Open your `storybook` URL(http:localhost:6006) and check browser devtools > console tab. If MSW is enabled in your browser, you'll be able to see this log.

    ![msw enabled on console](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ma5bel5uwcmof4u4vim8.png)


    ![msw enabled on console2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zgdwyvohdn51mlfkh1uw.png)

  5. You can also see the following log in the console tab if the request was intercepted by MSW.

    ![mocking intercepted by MSW](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zwzor2h1tbmz54eb5m0x.png)








## Issues

### `yarn test-storybook --coverage`is not printing the coverage result on console

#### Description

![Test result on console, coverage not shown](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5exyaevwmrk97dsrszsi.png)

You can see that all the tests passed, but the coverage result is not displayed. [This is a known issue.](https://github.com/storybookjs/addon-coverage/issues/13)

#### Workaround

  1. Install `nyc` as a dev dependency.
    ```
    yarn add nyc --dev
    ```
  2. Run this command instead.
    ```
    test-storybook --coverage --coverageDirectory coverage && nyc report --reporter=text -t coverage
    ```
    
    `nyc` prints the result on console. This time we tell `nyc` where to pick up the coverage report file(`./coverage`).

## Troubleshooting

### Testing Storybook-rendered iframe in Playwright

Let's say you wrote a end-to-end test to see if the following application flow works.

  1. A page renders log-in button.
  2. User clicks log-in button.
  3. log-out button is renders.

![Log-in button](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gkmfv90zf79rqwgw7to1.png)


![Log-out button](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jvjr81tawlka10row9fw.png)


This is the first version of the test using the rendered component in storybook.

```ts
test("it has logout button", async ({ page }) => {
  // navigate to the Stroybook component url
  await page.goto(
    "http://localhost:6006/?path=/story/example-page--logged-out",
  );

  // query log-in button element
  const loginButton = page.getByRole("button", { name: /log in/i });
  // fire click event
  await loginButton.click();
  // query log-out button element
  const logoutButton = page.getByRole("button", { name: /log out/i });
  // assert log-out button is rendered
  await expect(logoutButton).toBeVisible();
});
```

Here's the result of the test.

![test failure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1qydp5voc9udlosc78l6.png)

- Problem: `await page.goto()` only waits for the navigation of the document of the main frame, so it doesn't wait for subframe generated by `<iframe/>` to render. Let's fix this issue.

```ts
test("it has logout button", async ({ page }) => {
  // navigate to the Stroybook component url
  await page.goto(
    "http://localhost:6006/?path=/story/example-page--logged-out",
  );

  // wait for subframe to load
  await expect(page.getByTitle("storybook-preview-iframe")).toBeVisible();
  const frame = page.frame({ url: /http:\/\/localhost:6006\/iframe.html/ })!;

  // query log-in button element
  const loginButton = page.getByRole("button", { name: /log in/i });
  // fire click event
  await loginButton.click();
  // query log-out button element
  const logoutButton = page.getByRole("button", { name: /log out/i });
  // assert log-out button is rendered
  await expect(logoutButton).toBeVisible();
});
```

![timeout error](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ikzj7na7kvu6a5fr2jgr.png)

- Problem: it's still impossible to query elements since `loginButton` and `logoutButton` is in the subframe. We need to query on the document of the subframe.

```ts
test("it has logout button", async ({ page }) => {
  // navigate to the Stroybook component url
  await page.goto(
    "http://localhost:6006/?path=/story/example-page--logged-out",
  );

  // wait for subframe to load
  await expect(page.getByTitle("storybook-preview-iframe")).toBeVisible();
  const frame = page.frame({ url: /http:\/\/localhost:6006\/iframe.html/ })!;

  // query log-in button element
  const loginButton = frame.getByRole("button", { name: /log in/i });
  // fire click event
  await loginButton.click();
  // query log-out button element
  const logoutButton = frame.getByRole("button", { name: /log out/i });
  // assert log-out button is rendered
  await expect(logoutButton).toBeVisible();
});
```

![test success](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5frqvmh3x94j0v6x9xm4.png)

Now the test succeeds!

## References
- [How to know what to test](https://kentcdodds.com/blog/how-to-know-what-to-test)
- [Setting test runner in Storybook](https://storybook.js.org/docs/writing-tests/test-runner)
- [Unit testing in Storybook](https://storybook.js.org/docs/writing-tests/stories-in-unit-tests)
- [Visual testing in Storybook](https://storybook.js.org/docs/writing-tests/visual-testing)
- [Interaction testing in Storybook](https://storybook.js.org/docs/writing-tests/interaction-testing)
- [Test coverage in Storybook](https://storybook.js.org/docs/writing-tests/test-coverage)
- [End-to-end testing in Storybook](https://storybook.js.org/docs/writing-tests/stories-in-end-to-end-tests)
- [Integrate MSW and Storybook](https://storybook.js.org/addons/msw-storybook-addon)