Storybook

Setup storybook

Installation

  • Generate a new project.
npx storybook@latest init
  • Add storybook to existed project.
npx create-react-app my-app
cd my-app
npx sb init

Start storybook and visit

yarn storybook

If Error: spawn xdg-open ENOENT error, change script to storybook dev -p 6006 --ci

Interaction test

import { expect, fn, userEvent, within } from '@storybook/test';
export const Test = {
  args: {
    // some props for this story
  },
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    // if element should wait for api, use "await canvas.findByText("button", undefined, { timeout: 5e3 });" to get the button.
    await userEvent.click(canvas.getByRole("button"));
    await expect(args.onClick).toBeCalled();
  }
};

RWD Story

  • Defines RWD breakpoints in .storybook/preview.ts.
const customViewports = {
  Desktop: {
    name: "Desktop",
    styles: {
      width: "1280px",
      height: "720px",
    },
  },
  Mobile: {
    name: "Mobile",
    styles: {
      width: "768px",
      height: "720px",
    },
  },
};

const preview: Preview = {
  parameters: {
    viewport: {
      viewports: customViewports,
    },
  },
};
  • Specify defaultViewport in story.
export const Desktop: Story = {
  args,
  parameters: {
    viewport: {
      defaultViewport: "Desktop", // or "Mobile"
    },
  },
};

Decorator for react-router-dom

import { MemoryRouter } from "react-router-dom";

const meta = {
  title: "Components/Router",
  component: Router,
  decorators: [
    (Story) => (
      <MemoryRouter initialEntries={["/"]}>
        <Story />
      </MemoryRouter>
    ),
  ],
}

Arguments Mutation

import { useArgs } from "@storybook/preview-api";

export const Page: Story = {
  args: {
    currentPage: 5,
    onChange: fn(),
  },
  render: (args) => {
    const [_, updateArgs] = useArgs();

    function onChange(_: any, page: number) {
      args.onChange(_, page);
      updateArgs({ currentPage: page });
    }

    return <Pagination {...args} onChange={onChange} />;
  },
};

Global Decorators

  • reference
  • Rename preview.ts to preview.tsx and add following content.

const loginCookiesDecorator = (Story) => {
  document.cookie = "authToken=mock_token; path=/; SameSite=None; Secure";
  // clear cookie
  // document.cookie = "authToken=; path=/; SameSite=None; Secure";
  return <Story />;
};

const localStorageResetDecorator = (Story) => {
  window.localStorage.clear();
  return <Story />;
};

const TimeFeezeDecorator = (Story: any) => {
  Date.now = () => new Date("2024-08-10T09:27:45.000Z").getTime();
  return <Story />;
};

export const decorators = [localStorageResetDecorator, TimeFeezeDecorator, loginCookiesDecorator];

Setup Mock Service Worker

Installation

  • Run commands to add packages.
yarn add msw msw-storybook-addon -D
npx msw init public/
  • Initialize in ./storybook/preview.js
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

Mock Api in stories

import { http, HttpResponse } from 'msw'

const mockPost = http.post(`${HOST}/post/api/:id`, async ({ request, params }) => {
  const id = params.id;
  const payload = await request.json();
  return HttpResponse.json({
    foo: "foo",
  });
});

const mockGet = http.get(`${HOST}/get/api`, async ({ request, cookies }) => {
  if (!cookies.authToken) {
    return HttpResponse.json("auth failed", {
      status: 400,
    });
  }
  const url = new URL(request.url);
  const param = url.searchParams.get("id");
  return HttpResponse.json({
    foo: param,
  });
});

export const Demo = {
  args: {
    // others props for components
  },
  parameters: {
    msw: {
      handlers: [mockPost, mockGet],
    },
  },
};

Mock Websocket in stories

Only available in the work-in-progress version msw@next.

import { ws } from 'msw'

const websocketHandler = (() => {
  const chat = ws.link("ws://localhost:3000");
  return chat.on("connection", ({ client }) => {
    client.send("hello client");
    client.addEventListener("message", (event) => {
      console.log(event); //message from client
    });
    client.addEventListener("close", () => console.log("cleanup"));
  });
})();

export const Demo = {
  args: {
    // others props for components
  },
  parameters: {
    msw: {
      handlers: [websocketHandler],
    },
  },
};

Mock in Production

import { HttpHandler } from "msw";
import { setupWorker } from "msw/browser";

const handlers: Array<HttpHandler> = [];

const worker = setupWorker(...handlers);

worker
  .start({
    serviceWorker: {
      url: `/${baseURL}/mockServiceWorker.js`,
    },
  })
  .then(() => {
    const root = ReactDOM.createRoot(
      document.getElementById("root") as HTMLElement,
    );
    root.render(<App />);
  });

Setup storybook-test-runner

Installation

yarn add @storybook/test-runner -D
npx playwright install --with-deps

Add script

package.json

{
  "scripts": {
+    "test-storybook": "test-storybook --testTimeout=60000"
  }
}

Run storybook

yarn storybook

Run Tester

  • With running storybook
yarn test-storybook --url http://127.0.0.1:6006
  • Start storybook and test with one command:

    • Installation
    yarn add -D concurrently http-server wait-on
    
    • Add script

    package.json

    {
      "scripts": {
    +    "test-storybook:ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"yarn build-storybook --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:6006 && yarn test-storybook\""
      }
    }
    

Coverage Report

  • Installation
yarn add -D @storybook/addon-coverage
  • Change script

package.json

{
  "scripts": {
    "test-storybook": "test-storybook"
+   "test-storybook:coverage": "test-storybook --coverage && npx nyc report --reporter=lcov -t coverage/storybook --report-dir coverage/storybook"
  }
}
  • Convert report to lcov
npx nyc report --reporter=lcov -t coverage/storybook --report-dir coverage/storybook
  • Define the coverage threshold by creating a .nycrc file with the following content:
{
  "exclude": ["src/exclude/**"],
  "all": true,
  "check-coverage": true,
  "branches": 80,
  "functions": 80,
  "lines": 80,
  "statements": 80
}