🕯️

storybook-testing-lit を作って vitest で動かす

2022/09/24に公開

はじめに

@storybook/testing-react@storybook/testing-vue3 という npm ライブラリを知っているだろうか?

Storybook における Story の記述フォーマットには CSF というフォーマットが用いられているが、これの中には play というフィールドが含まれており、ここにレンダリングされた Story に対するインタラクションなどが記述できる。

加えて、ここにアサーションを記述することで、Storybook でコンポーネントのテストが実施できる。つまり、story をテストケースとして運用ができる。
これをサポートするものが、@storybook/addon-intaractions だ。
ただ、テストスイートを実行するためには Storybook を実行して、各 Story を閲覧することが必要だ。

さらにこれをサポートするものが、@storybook/test-runnner だ。
@storybook/test-runnner は playwright を実行してヘッドレスブラウザ上で story をクローリングしてテストの実行とテスト結果の収集を行う。

これらに対して、@storybook/testing-react や @storybook/testing-vue3 ライブラリは Story のレンダリングと play の実行を node.js の環境 (JSDOM や happy-dom を使って) で行いテストスイートを実行するライブラリとなる。
これは実行速度や実行の容易性などの面でメリットがある。

@storybook/testing-react が最も開発が行われており、@storybook/testing-vue3 などは CSF3 フォーマットに対応していないなどが現状だ。

私は最近 Web Components を作ることが多く、これに対応したライブラリが欲しいのだが、まだなさそうなので作ろうと思った。

コードは以下
https://github.com/sterashima78/storybook-testing-lit-vitest

@storybook/testing-react について

基本的には composeStoriescomposeStory という関数を公開している。

使い方は README を見れば理解できるだろう。
https://github.com/storybookjs/testing-react#usage

composeStory は 特定の Story と Story から default export されている Story 全体のメタ情報を受け取って、 Story をレンダリングする関数と各 Story のメタ情報を返してくれる。
Story だけではなくメタ情報も渡すのは、Storybook は Global Config (preview.js に記述する) やメタ情報に共通の設定を記述できるという仕様が存在するためだ。
そのため、Global Configを設定する関数も公開している。

composeStories は composeStory の複数版で、モジュール全体を受け取って各ストーリーに対して composeStory をした結果を返してくれる。

@storybook/testing-react のやっていることを雑にまとめると、
『継承されるパラメータをマージして、render や play 関数に story のメタ情報を渡して実行可能にする』という感じだ。
実装を見るとわかるがそれほどコードも大きくない。

実装方針

前述の @storybook/testing-react の役割を見てピンとくる人もいると思うが、そもそもフレームワーク特有の処理がほぼない。

唯一それらしい部分がグローバルレンダラーで、これはメタ情報や Story でレンダリング内容が定義されていないときに使われるレンダリング実装だ。
react では以下のようなもの。
https://github.com/storybookjs/testing-react/blob/v1.3.0/src/utils.tsx#L7-L17
個人的にこの機能にニーズが無いので今回はこれを削除してレンダラが存在しないときはエラースローしてしまう。

また、フレームワークごとに特有の型付けが必要な部分があるのでこれを適切なものに変更する。
これでだいたい動くものができてしまう。

これに加えて、@storybook/testing-react にはない機能を追加したい。
@storybook/addon-actions には CSF で設定することで自動的にモック関数を args に挿入する機能がある。

https://storybook.js.org/docs/react/essentials/actions

これは Storybook 上でユーザインタラクションに対して適切に応答しているかを確認する上で役立つ。さらに @storybook/addon-interactions においては、引数に渡した関数をコールしたかどうかのアサーションができる。

https://storybook.js.org/docs/react/essentials/interactions#writing-interactions

ただ、残念ながら @storybook/testing-react にはこのようなモック関数を挿入する機能がないため、そのままだと関数の実行に対するアサーションをすることができない。
これはライブラリ側でやってほしいのでこれに対応する機能を一部実装する。

実装

まずは、グローバルレンダラーを削除する。
以下を
https://github.com/storybookjs/testing-react/blob/v1.3.0/src/index.ts#L83-L93

以下のようにする

  const renderFn = typeof story === 'function' ? story : story.render ?? meta.render;
  const finalStoryFn = (context: StoryContext<WebComponentsFramework, GenericArgs>) => {
    if(!renderFn) {
      throw new Error("render is not found.")
    }
    return renderFn(context.args, context);
  };

次は action の自動挿入だが、これは ArgTypes に設定がある時に行うようにする。
Web Components ではコールバック関数を引数に取るよりもイベントの発行を行い、利用者がイベントリスナを登録するケースが多いため、コンポーネント定義自体にコールバック関数がプロパティとして定義されていることが少ないからだ。
(そもそも attribute を基本のインターフェースと考えれば文字列でシリアライズできるものしかコンポーネントには渡せない)

例えば my-click カスタムイベントを発行するコンポーネントがある時、
以下のように argTypes を設定する。

export default {
   :
   <snip>
     : 
    argTypes: {
        onClick: { action: true },
    },
}

すると Story では以下のように render が書けるような感じだ。

export const Story = {
    render(args){
        return html`
           <my-button @my-click=${args.onClick}></my-button>
	`
    }
}

以下の combinedArgs で継承した値などをまとめて args を作っている部分があるので、ここに突っ込む。
https://github.com/storybookjs/testing-react/blob/v1.3.0/src/index.ts#L122-L125

例えば以下のような感じだ。
ここで、fnjest-mockfn だ。

  const combinedArgType = globalConfig.argTypes || meta.argTypes || {}

  const combinedArgs = {
    ...meta?.args,
    ...story.args,
    ...Object.fromEntries(Object.entries(combinedArgType)
        .map(([key, val])=> [key, val && val.action && fn()])
        .filter(([_, val]) => !!val)
    ),
  } as GenericArgs

あとは型付けをいくつかイジる必要があるが、特筆して説明したいる部分はないので、
残りはソースを見るのがいいだろう。
気をつけるべきは、フレームワークごとに公開している型の種類が違うので、React のものを WebComponents からのインポートに変えても型が公開されていないことがある。
そういう場合はソースを見て内部的な型を拾ってきたり、別パッケージから再エクスポートしているものがあればそっちを見るようにする。

動かす

動かしていこう。
storybook 7.0.0-alpha.33 を使っている。

以下が main.js だ。 preview.js には設定をしていない。

.storybook/main.js
const path = require('path');
module.exports = {
  "stories": [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    '@storybook/addon-interactions'
  ],
  "framework": {
    "name": "@storybook/web-components-webpack5",
    "options": {}
  },
  webpackFinal: async (config) => {
    config.resolve.extensionAlias = config.resolve.extensionAlias
      ? { ...config.resolve.extensionAlias, ".js": [".js", ".ts"] }
      : { ".js": [".js", ".ts"] };
    return config;
  },
}

ボタンコンポーネントを作る。

stories/Button.ts
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement("my-button")
export class MyButton extends LitElement {
    @property()
    label = ""

    override render() {
        return html`<button @click=${()=> this.dispatchEvent(new CustomEvent("my-click"))}>${this.label}</button>`
    }
}

export type MyButtonProps = {
    label: string
}

Story は以下。
testing-library は shadowRoot を突き抜けないので、そのへんをうまくやるための工夫が必要だ。

stories/Button.stories.ts
import { Story, Meta } from "@storybook/web-components";
import type { MyButtonProps } from "./Button.js";
import { html } from "lit";
import { expect } from '@storybook/jest';
import { within, userEvent } from '@storybook/testing-library';
import "./Button.js"

export default {
    title: "my-button",
    component: "my-button",
    argTypes: {
        onClick: { action: true },
    },
    render(args){
        return html`<my-button @my-click=${args.onClick} label=${args.label}></my-button>`
    }
} as Meta<MyButtonProps & {onClick: Function}>

export const Default: Story<MyButtonProps & {onClick: Function}> = {
    args: {
        label: "default",
    },
    async play({ canvasElement, args }){
        const canvas = within(canvasElement.querySelector("my-button")?.shadowRoot as unknown as HTMLElement);
        await userEvent.click(canvas.getByRole('button'));
        expect(args.onClick).toHaveBeenCalled()
    }
}

Storybookを動かすと、addon-intractions がちゃんと動いていることがわかる。

テストを書く。
vitest と happy-dom をインストールし、
以下のように設定する。

vite.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
    test: {
        globals: true,
        environment: 'happy-dom',
    },
})

そして以下のテストを書く

Button.test.ts
import { describe, it } from 'vitest';
import { composeStories } from "../src/index.js";
import { render } from "lit";
import type { IWindow } from 'happy-dom'
import "./Button.js"
declare global {
    interface Window extends IWindow {}
}
import * as stories from './Button.stories.js';
const { Default } = composeStories(stories);
describe("my-button", ()=> {
    it("click", async ()=> {
        const canvasElement = document.createElement("div")
        document.body.appendChild(canvasElement)
        render(Default({}), canvasElement)
        await window.happyDOM.whenAsyncComplete()
        Default.play({ canvasElement })
    })
})

テストを実行しよう。

$ npx vitest run

 ✓ stories/Button.test.ts (1)

Test Files  1 passed (1)
     Tests  1 passed (1)
  Start at  08:00:48
  Duration  1.07s (transform 401ms, setup 0ms, collect 268ms, tests 13ms)

また、play 関数のクリック部分をコメントアウトするとちゃんと失敗する。

おわりに

Storybook を中心に開発を行っていくと、コンポーネントに対する見た目や振る舞いの情報を CSF にまとめていく事ができる。

運用にもよるが、 Storybook はドキュメントの側面も持ちやすいため、

  • コンポーネントがどのような見た目か?
  • コンポーネントをどのように使うか?
  • コンポーネントはどのように動くか?

などがまとまっていき、 Storybook で閲覧できる状態になることは有用といえる。

CSF に記載した play 関数を node.js 環境で実行できることは、 CI などで日常的に動かしやすくなることに繋がり、気がついたら Storybook が壊れていたということも防げるだろう。

一方で、 node.js 環境での実行は、見た目に関する評価が行えていないので、@storybook/test-runner などを用いて VRT の実行も併用したほうが良いだろう。

ここに書いたとおり、それほど難しい内容ではないので、 既存実装が CSF 3 に対応していない Vue や、実装が存在していないその他のフレームワークユーザも、作ってみれば役に立つのではないかと思う。

Discussion