Visual Tests and Snapshot Tests in Storybook Test-Runner's Play Function
Tags: webdev, storybook
Published: 2024-01-07 16:07:46

Visual tests and snapshot tests are crucial in frontend development. Storybook's Test-Runner uses components' stories as test cases to ensure accurate functionality. We utilize the Test-Runner to capture visual and DOM changes based on the component's state. This document introduces how to use Visual Tests and Snapshot Tests within the Play Function of Storybook Test-Runner.

## Issue
While the [Storybook documentation](https://storybook.js.org/docs/writing-tests/storyshots-migration-guide) touched on Visual Tests (Image Snapshot) and Snapshot Tests (HTML Snapshot), it did not account for the fact that the `postVisit` point occurs after the completion of the operation, making we can't test the intermediate states.

## Solution
> You can find code here: https://github.com/flyinglimao/storybook-test-runner-example

In the `preVisit` phase, we add a Snapshot function to the Window object. (Some declaration for TS may be required.)

```ts
// .storybook/test-runner.ts
import { type TestRunnerConfig } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";

const config: TestRunnerConfig = {
  setup() {
    expect.extend({ toMatchImageSnapshot });
  },
  async preVisit(page) {
    if (await page.evaluate(() => !("takeSnapshot" in window))) {
      await page.exposeBinding("takeSnapshot", async ({ page }) => {
        const elementHandler = await page.$("#storybook-root");
        const innerHTML = await elementHandler?.innerHTML();
        expect(innerHTML).toMatchSnapshot();
      });
    }

    if (await page.evaluate(() => !("takeScreenshot" in window))) {
      await page.exposeBinding("takeScreenshot", async ({ page }) => {
        const image = await page.locator("#storybook-root").screenshot();
        expect(image).toMatchImageSnapshot();
      });
    }
  },
};

export default config;
```

Then, call the Snapshot function in the Play function.

```ts
// .storybook/button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { expect, fn, userEvent, within } from "@storybook/test";

import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Button",
  component: Button,
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    onClick: fn(),
  },
  play: async () => {
    await window.takeSnapshot?.();
    await window.takeScreenshot?.();
    // Can be called multiple times
  },
};
```

## Reasons for Not Adding in Prepare
In the Prepare phase, `expect` is not yet injected, making it inaccessible.

```ts
// .storybook/test-runner.ts
...
const config: TestRunnerConfig = {
  async prepare({ page, browserContext, testRunnerConfig }) {
    ...
    // default prepare
    ...
    // expose takeSnapshot
    await page.exposeBinding("takeSnapshot", async ({ page }) => {
      const elementHandler = await page.$("#storybook-root");
      const innerHTML = await elementHandler?.innerHTML();
      expect(innerHTML).toMatchSnapshot();
    });
  },
  ...
```

![Error when expose function in prepare phase](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tlnvt6qnyaduordacowt.png)

The `expect` function is available from `setup`. However, `setup` is a function without arguments.

## Conclusion
When setting up the testing environment, it was essential to capture not only the final state of the component but also intermediate states. In the quest to test the intermediate states of components, this method was discovered.