🗿

Nuxt3(RC)でもStorybookもtesting-libraryもchakraも使いたいんじゃ!

2022/10/05に公開

ワシはNuxt3でもReactと同じような構成で実装したいんじゃ!!!

はじめに

私はいつもReact / Next.jsを使って開発しております。
最近副業で新たな開発が始まりました。これまではVue2系を使っていたのですが新たな環境を作るということでそこではVue3を使いましょうという話がでてきました。
VueといえばNuxtということで調べたところVue3は絶賛開発中のNuxt3系から、今はRC版ですが2022年の夏にはリリースされるということで3系を使うことに決まりました。(なお現在は表記が変わり2022年の秋となっています)

ここの新規開発は私がメインになるので「ここはいつも使っている構成でリポジトリを立ち上げてしまおう」ということで環境を立ち上げていくことになりました。

そこでここ最近本業の方でstyled-componentsからchakraに乗り換え開発速度が10倍(当社比)になったので、Vueでもchakraを使いたいという思いからchakraを導入することにしました。
またもちろん単体テストが書ける環境も用意したいので普段使っているtesting-libararyを導入することにします。
また今回作るのは今あるサービスの派生になるのですが、元のプロジェクトにはデザインシステムが存在するのでその辺りを確認できるようとしてStorybookも必要そうです。
構成は決まりました、それでは早速各種ライブラリをyarn installして・・・はい、出来上がり!!!

とはならないわけです。

雨にも負けず、風にも負けず、雪にも夏の暑さにも負けなかった結果、なんとか環境の立ち上げに成功しました。
Nuxt3で同じようなことをしたい方の参考になればと思い今回筆を取りました。
みなさんの助けになれば幸いです。

Nuxtの環境を立ち上げる

まずはNuxt3の環境を作っていきましょう。

https://v3.nuxtjs.org/getting-started/installation
こちらがインストールについて記載のあるドキュメントです。
こちらに沿って実行していきましょう。

npx nuxi init <任意のプロジェクト名>

そうしたら作成したプロジェクトに飛びnode_modulesをインストールします。
私はyarnを使っているのでyarnでコマンド記述しますが、npmを使っている人は読み替えていただければ幸いです。

yarn install

その後はプロジェクトを立ち上げます。
そうすると下記のような画面が出るはずです。

yarn dev -o

yarn dev

これでひとまずnuxtのプロジェクトの作成が完了しました。
この後の説明についてはNuxt3の機能自体の説明は省かせていただきます。
またVSCodeでの設定周りも今回は割愛します。
必要に応じてみなさん公式ページをご確認ください。

chakraの導入

それではここからchakraを導入し簡単なコンポーネントを実装してみましょう。
と、ここで残念なお知らせです。
Vue3系ではまだchakraは完全には対応が完了していないようです。

https://github.com/chakra-ui/chakra-ui-vue/issues/116

現在実装中とのことで全てのコンポーネントが使える状態ではありません。
しかし今私が求めているのはUIコンポーネントとしてのchakraではなく、あの書きやすいStyleの当て方ができるchakraです。
UtilityなCSSを使えるという利点を求めているので今回はそのままchakraを使います。
実際のところ使いたいアレがない!みたいな可能性が非常に高いですので実プロダクトへ使う場合は十分気をつけてください。

ということで開発中とのことですがこのまま突き進みます。

https://github.com/chakra-ui/chakra-ui-vue-next

それではこちらを導入していきましょう。

yarn add @chakra-ui/vue-next

はい、これでimportして読み込んであげたらきっと動くはずです。
やってみましょう!!!

動きませんでしたね。

それではここから初期設定をしていきます。

初期設定をするその前に

初期設定をしていくのですが、今表示されている画面はすでに背景がついていたりとちょっとデバックしにくそうですね。
またrootのディレクトリは使い慣れているsrcにしたいです。
なので簡単にNuxt側の設定を変えていきます。

まずは色々と今後面倒になるのでrootのディレクトリをsrcに設定します。
これはnuxt.config.tsで変更可能です。

nuxt.config.ts
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
  srcDir: "src/",
});

これでsrcディレクトリがrootになりました。
それでは試しに適当なページを作ってみます。
今まであったapp.vueはsrc/pagesの配下に移動しindex.vueにリネームしておきます。

今回はtest.vueというファイルを作りました。
HelloWorldとだけ表示されるとてもシンプルなページです。

src/pages/test.vue
<template>
  <div>
    Hello World
  </div>
</template>

/testにアクセスしたらHelloWorldが表示されたと思います。
今後はこちらを編集していきます。

chakraの登録

それでは再び戻りましてchakraの初期設定です。
chakraは使用する際にProviderの登録などが必要です。
それをどこでやれば良いのかというとNuxt3ではpluginsというディレクトリで実施可能です。
ここでVue2系ではよく見たであろうVue.use的なことができます。

https://v3.nuxtjs.org/guide/directory-structure/plugins

それではchakraの場合どうすべきか。

https://github.com/chakra-ui/chakra-ui-vue-next/blob/develop/playground/src/main.ts

リポジトリにplaygroundのコードが存在しているのでこちらを参考に実装していきます。

src/plugins/chakra-ui.ts
import ChakraUIVuePlugin, { chakra, extendTheme } from "@chakra-ui/vue-next";
import { domElements } from "@chakra-ui/vue-system";
import { defineNuxtPlugin, NuxtApp } from "nuxt/app";

export const plugin = (nuxtApp: NuxtApp) => {
  // (1) カスタムテーマの作成
  const theme = extendTheme({});
  // (2) chakraのPluginを登録
  nuxtApp.vueApp.use(ChakraUIVuePlugin, { extendTheme: theme });
  // (3) <chakra.div>のような書き方ができるようにcomponentとして登録する
  domElements.forEach((tag) => {
    nuxtApp.vueApp.component(`chakra.${tag}`, chakra(tag));
  });
};

export default defineNuxtPlugin(plugin);

このようになります。
(1)のextendThemeは上書きしたいstyleがあればこちらで上書き可能です。
第一引数がオーバーライドするテーマ、第二引数がベースとなるテーマです。
chakra側が用意しているテーマではなく自前のプロジェクトで使うデザインシステム以外のテーマを許容したくない場合はこちらも指定すると良いかと思います。

https://vue.chakra-ui.com/extending-theme

実際に動かしてみる

では先ほど作ったtest.vueにchakraのコードを当て込んでみます。

src/pages/test.vue
<script setup>
import { CBox } from '@chakra-ui/vue-next'
</script>
<template>
  <CBox p="8">
    Hello World
  </CBox>
</template>

先ほど作った元の画面がこちらです。

お次にpaddingを8当て込んだ場合がこちらです。

きちんと動いていますね!
それでは試しにCBoxではなくchakra.divを使ってみます。

src/pages/test.vue
<template>
  <chakra.div p="8">
    Hello World
  </chakra.div>
</template>

はい、こちらも動きました。
Utilityなstyleがバッチリ当たっていますね。

ESLint / Prettierの設定

とここまできて気づきました。
自動フォーマットされません。
ということでlint周りの設定をしていきたいと思います。
皆さんお好みの設定があると思いますのでそちらに合わせて設定ください。
下記には今回とりあえずで設定した内容を入れてありますので、もし困ったら下記を参考に継ぎ足ししてみてください。

yarn add -D typescript @nuxtjs/eslint-config-typescript eslint@latest eslint-plugin-nuxt@latest eslint-config-prettier eslint-plugin-prettier prettier
.eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  extends: [
    '@nuxtjs/eslint-config-typescript',
    'plugin:nuxt/recommended',
    'plugin:prettier/recommended',
  ],
  rules: {
    'vue/multi-word-component-names': 0,
    'prettier/prettier': ['error', { singleQuote: true, semi: false }],
  },
}
.prettierrc.js
module.exports = {
  tabWidth: 2,
  singleQuote: true,
  semi: false,
  trailingComma: 'all',
  bracketSpacing: true,
  arrowParens: 'avoid',
  vueIndentScriptAndStyle: false,
}
package.json
{
  "scripts": {
    "build": "nuxt build",
    "dev": "nuxt dev",
    "generate": "nuxt generate",
    "preview": "nuxt preview",
    "postinstall": "nuxt prepare",
    "lint": "eslint . --ext .ts,.vue"
  },
  "devDependencies": {
    "@nuxtjs/eslint-config-typescript": "^11.0.0",
    "eslint": "^8.24.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-nuxt": "^4.0.0",
    "eslint-plugin-prettier": "^4.2.1",
    "nuxt": "3.0.0-rc.11",
    "prettier": "^2.7.1",
    "typescript": "^4.8.4"
  },
  "dependencies": {
    "@chakra-ui/vue-next": "^1.0.0-alpha.15"
  }
}

コンポーネントの実装

そしたらここから一度コンポーネントを実装していきたいと思います。
コンポーネントを用意しないとテストもStoryもかけませんからね。
とりあえずよく解説で出てくる簡単なカウンターをコンポーネントとして用意しようと思います。
ここは特に説明しません。

src/components/Counter/index.ts
export { default as Counter } from './Counter.vue'
src/components/Counter/Counter.vue
<script lang="ts" setup>
import { defineProps, ref } from 'vue'
import { CBox, CButtonGroup, CButton, CIcon } from '@chakra-ui/vue-next'

type Props = {
  /** 初期値設定 */
  defaultValue?: number
  /** 最大のカウント */
  maxCount: number
}
const props = defineProps<Props>()

const count = ref<number>(props.defaultValue ?? 0)

const increment = () => {
  if (count.value < props.maxCount) {
    count.value++
  }
}
const decrement = () => {
  if (count.value > 0) {
    count.value--
  }
}
</script>
<template>
  <CBox as="div" display="grid">
    <CBox as="p"> 現在のカウント: {{ count }} </CBox>
    <CButtonGroup mt="2">
      <CButton
        type="button"
        aria-label="カウントを減らす"
        :is-disabled="count <= 0"
        @click="decrement"
      >
        <CIcon name="minus" />
      </CButton>
      <CButton
        type="button"
        aria-label="カウントを増やす"
        :is-disabled="count >= props.maxCount"
        @click="increment"
      >
        <CIcon name="add" />
      </CButton>
    </CButtonGroup>
  </CBox>
</template>

これをpages/test.vueで取り込みました。

pages/test.vue
<script setup>
import { CBox, CReset } from '@chakra-ui/vue-next'
import { Counter } from '@/components/Counter'
</script>
<template>
  <CBox p="12">
    <CReset />
    <Counter :default-value="1" :max-count="10" />
  </CBox>
</template>

このような表示になります。

ということでこれでコンポーネントが作れました。
それではまずはStorybookを用意していきましょう。

Storybookの導入

それではStorybookの導入です。

https://storybook.js.org/docs/vue/get-started/install#troubleshooting

がここにある通り叩くと普通に動きません。
まず、下にあるトラブルシューティングにVue3向けのコマンドがありますのでこれを参考にします

npx storybook init --type vue3

しかしこれだけではダメです。
nuxt3はviteで動いているのでviteに合わせたオプションを付け加える必要があります。
storybook for viteに関する記事を参考にコマンドを叩きます。

https://storybook.js.org/blog/storybook-for-vite/

そうするとコマンドはこのようになりますので実行します。

npx sb init --type vue3 --builder @storybook/builder-vite

初期化が終わったらStorybookを立ち上げます。

Welcome to Storybookの画面が出たら成功です。

Storybookの実装

それではCounterコンポーネントのStoryを作ってみましょう。

src/components/Counter/__stories__/Counter.stories.ts
import { Story } from '@storybook/vue3'
import { Counter } from '..'

export default {
  title: 'components/Counter',
  component: Counter,
}

const Template: Story<typeof Counter> = args => ({
  components: { Counter },
  setup() {
    return {
      args,
    }
  },
  template: `
    <Counter v-bind="args" />
  `,
})

export const Default = Template.bind({})
Default.storyName = 'Counter'
Default.args = {
  maxCount: 10,
  defaultValue: 1,
}

はい、それではStorybookで確認しましょう!
残念ながらエラー画面になります。

Storybookでchakraを使えるようにする

はい、エラーに書いてある通り reading '__breakpoints'とあるのでこれはchakraが使えてないという話です。
思い返していただければわかると思いますが、初期設定した際にpluginとしてchakraを登録していたと思います。
これをStorybookでも実施する必要があります。

https://storybook.js.org/docs/vue/writing-stories/decorators

ということで.storybook/preview.jsで2つのことをやります。

  • chakraを使えるように登録する
  • chakraのCResetを読み込ませる

ということでpreview.jsをこのように書き換えます。

.storybook/preview.js
import { app } from '@storybook/vue3'
import ChakraUIVuePlugin, { chakra, CReset } from '@chakra-ui/vue-next'
import { domElements } from '@chakra-ui/vue-system'

app.use(ChakraUIVuePlugin)
domElements.forEach(tag => {
  app.component(`chakra.${tag}`, chakra(tag))
})

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}
export const decorators = [
  story => ({
    components: { CReset, story },
    template: `
      <div>
        <CReset />
        <chakra.div p="12">
          <story />
        </chakra.div>
      </div>
    `,
  }),
]

表示されましたね!

補足

補足ではありますが、例えばコンポーネント間で依存関係がある場合もあると思います。
デザインシステムで用意したボタンを別のコンポーネントでimportしているようなケースですね。

この場合 import { Button } from '@/components/Button'のようなパスを解決できずにエラーになることがあります。
その場合 .storybook/main.js を書き換える必要がありましたので補足として記載させていただきます。

.storybook/main.js
const path = require('path')

module.exports = {
  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
  ],
  framework: '@storybook/vue3',
  core: {
    builder: '@storybook/builder-vite',
  },
  features: {
    storyStoreV7: true,
  },
  async viteFinal(config, { configType }) {
    return {
      ...config,
      resolve: {
        ...config.resolve,
        alias: {
          ...config.resolve.alias,
          '@/components': path.resolve(__dirname, '../src/components'),
        },
      },
    }
  },
}

testing-libraryの導入

それでは最後です。
testing-libraryを導入していきます。
これでカウンターのテストを追加していこうと思います。

まずは下記のコマンドを実行します。

yarn add -D vitest @vue/test-utils jsdom @testing-library/vue @testing-library/jest-dom

設定ファイル

コマンドだけでは使えるようにはならないので、諸々の設定を対応していきます。
まずはvite用のテスト設定を追加していきます。

https://vitest.dev/config/

vitest.config.ts
import { defineConfig } from 'vitest/config'

import Vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [Vue()],
  test: {
    alias: {
      'test/*': '/test/*',
    },
    // 毎回importしたくないのでglobalsをtrueに変更
    globals: true,
    environment: 'jsdom',
    // setup用のファイルを指定(後述します)
    setupFiles: ['test/setup.test.ts'],
    include: ['./src/**/*.test.*'],
    // カバレッジの設定
    coverage: {
      reporter: ['text', 'text-summary', 'html'],
      include: ['src/**/*.{js,jsx,ts,tsx,vue}'],
      exclude: ['src/**/__stories__/*', 'src/**/__tests__/*'],
      all: true,
    },
    // モックは自動クリアにしてあります
    clearMocks: true,
  },
})

お次はnuxt.config.tsを少し編集します。
tsconfig周りの設定を追加しておかないと型定義周りでエラーになってしまうので・・・

nuxt.config.ts
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
  srcDir: 'src/',
  typescript: {
    tsConfig: {
      compilerOptions: {
        paths: {
          'test/*': ['test/*'],
          '@/*': ['src/*'],
        },
        types: ['vitest/globals'],
      },
      include: ['src/**/*', 'test/**/*', 'vitest.config.ts'],
    },
  },
})

これでひとまずconfig周りは完了です。

テスト実行用のsetupファイルの作成

はい、早速実装していきましょうと言いたいのですが、このままテストコードを書いても実は動きません。
またせっかくtesting-libraryを使うのですから @testing-library/jest-dom のmatcherを使いたいわけです。

ということでsetup用のファイルを作ります。

test/setup.test.ts
import matchers from '@testing-library/jest-dom/matchers'

expect.extend(matchers)

expectはvitestをglobalで読み込んでいるのでそのexpectに対して拡張をしています。
これでtoBeInTheDocumentなどtesting-libraryで使えるmatcherが使えるようになりました。
ただこれだと型定義が読み込めない問題があります。
expect(hoge).toBeInTheDocument()とすると現時点ではエラーになります。
これについてはissueでも現在やりとりされております。

https://github.com/testing-library/jest-dom/issues/427

ということでこちらのissueに沿ってsetupファイルを書き直します。

test/setup.test.ts
import matchers from '@testing-library/jest-dom/matchers'
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
declare global {
  namespace Vi {
    interface Assertion<T> extends TestingLibraryMatchers<void, T> {}
  }
}

expect.extend(matchers)

これでOKです。
expect(hoge).toBeInTheDocument()としても型エラーはでなくなったはずです。
これでjestと同じ感覚でtesting-libraryを使うことができるようになりました。

render時のconfig設定

それではテストを書いていきたいのですが実はもう一つ課題があります。
それはコンポーネントでchakraを使っているためrender時にchakraが動くようにしておく必要があります。
storybookの設定と同じです。
これには2通りのアプローチがあります。

  • testing-libraryのrender関数をoverrideする
  • 設定をexportしておきテストファイル側で読み込みrender時に渡せるようにする

前者でもよかったのですが、今回はサンプルの実装なのでクイックにできる後者で実装していきます。
後者で実装する場合はhygenテンプレートなどを使いコンポーネントを作った際にテストファイルも作りそのimportの中に含めるようにしてあげるのをおすすめします。

config.ts
import { GlobalMountOptions } from '@vue/test-utils/dist/types'
import ChakraUIVuePlugin, { chakra } from '@chakra-ui/vue-next'
import { domElements } from '@chakra-ui/vue-system'

const components = domElements.reduce<GlobalMountOptions['components']>(
  (acc, tag) => {
    return {
      ...acc,
      [`chakra.${tag}`]: chakra(tag),
    }
  },
  {},
)
export const global: GlobalMountOptions = {
  plugins: [ChakraUIVuePlugin],
  components,
}

テストの実装

ここまでできてようやくテストの実装です。
カウンターコンポーネントの特徴としては

  • 現在のカウント数が表示されている
  • デクリメントボタンがありクリックするとカウントが−1される
  • インクリメントボタンがありクリックするとカウントが+1される
  • defaultValueで渡したpropsがあればその値、なければ0が初期値として設定される
  • maxCountでカウントの上限を設定する
  • 上限までいくとインクリメントボタンはdisabledになる
  • 0(下限)までいくとデクリメントボタンはdisabledになる

という感じでしょうか。
一つ一つ実装すると時間がかかりますので実際にこのテストを書いたものがこちらです。

src/components/Counter/__tests__/Counter.test.ts
import { render, screen, fireEvent } from '@testing-library/vue'
import { global } from 'test/config'
import { Counter } from '..'

describe('Counterのテスト', () => {
  describe('初期表示のテスト', () => {
    test('defaultValueが未設定であれば初期値は0になること', () => {
      render(Counter, { props: { maxCount: 10 }, global })
      expect(screen.getByText('現在のカウント: 0')).toBeInTheDocument()
    })
    test('defaultValueへ任意の値を渡すと初期値に設定されること', () => {
      render(Counter, { props: { defaultValue: 4, maxCount: 10 }, global })
      expect(screen.getByText('現在のカウント: 4')).toBeInTheDocument()
    })
  })
  describe('ボタンに関するテスト', () => {
    describe('incrementボタンのテスト', () => {
      it('インクリメントボタンをクリックするとカウントが+1され上限に達した場合disabledとなること', async () => {
        render(Counter, { props: { defaultValue: 1, maxCount: 2 }, global })
        // 初期表示
        expect(screen.getByText('現在のカウント: 1')).toBeInTheDocument()
        expect(
          screen.getByRole('button', {
            name: 'カウントを増やす',
          }),
        ).toBeEnabled()
        // クリック
        await fireEvent.click(
          screen.getByRole('button', {
            name: 'カウントを増やす',
          }),
        )
        expect(screen.getByText('現在のカウント: 2')).toBeInTheDocument()
        expect(
          screen.getByRole('button', {
            name: 'カウントを増やす',
          }),
        ).toBeDisabled()
      })
    })
    describe('decrementボタンのテスト', () => {
      it('デクリメントボタンをクリックするとカウントが-1され0になった場合disabledとなること', async () => {
        render(Counter, { props: { defaultValue: 1, maxCount: 2 }, global })
        // 初期表示
        expect(screen.getByText('現在のカウント: 1')).toBeInTheDocument()
        expect(
          screen.getByRole('button', {
            name: 'カウントを減らす',
          }),
        ).toBeEnabled()
        // クリック
        await fireEvent.click(
          screen.getByRole('button', {
            name: 'カウントを減らす',
          }),
        )
        expect(screen.getByText('現在のカウント: 0')).toBeInTheDocument()
        expect(
          screen.getByRole('button', {
            name: 'カウントを減らす',
          }),
        ).toBeDisabled()
      })
    })
  })
})

カバレッジを出してみる

これでテストが実行でき、さらに通るようになりました。
では最後にカバレッジを表示してみます。

package.json
    "test": "vitest --environment jsdom",
    "test:coverage": "vitest run --coverage  --environment jsdom"

この2つを記述しておきコマンド実行でテストが動くようにしておきました。
通常の場合はyarn testで大丈夫です。
今回は

yarn test:coverage

を実行します。
するとパッケージが足りないとでるのでインストールしてもらってください。
そうするとカバレッジが取得できるようになります。
みていただければわかると思いますがお馴染みのカバレッジファイルが取得できます。

これでコンポーネントの単体テスト環境も整いました。

さいごに

これでnuxt3でもReactのような構成で実装できるようになりました。
今回はchakraを使ってみたのですが、Element plusやVuetifyを使う方もいると思いますので、今回のchakra向けの設定部分を読み替えて使っていただけたら幸いです。
私自身nuxtは今回初めて使ったので「こっちの方が良い」といったことがあれば是非ご指摘いただけますと幸いです。

GitHubで編集を提案

Discussion