Next.js & Tailwind CSS & Storybookのフロントエンド環境の構築メモ

## 概要
Next.js & Tailwind CSS & Storybookのフロントエンド環境の構築メモ。

## 前提
- GitHubを使用
- Chromaticを使用
- 各種インストールはバージョンを指定せずに実行 （2023/03/27〜28辺りで実行)
- Prettierを使用

## 構成

- Next.js v13.2.4
- React v18.2.0
- Tailwind CSS v3.2.7
- Storybook（react） v6.5.16
- TypeScript v5.0.2

## 手順

### Next.JS

create-next-appを実行して構築する。

```
npx create-next-app@latest sample-project --typescript --eslint

✔ Would you like to use `src/` directory with this project? … Yes
✔ Would you like to use experimental `app/` directory with this project? … No
✔ What import alias would you like configured? … @/*
```

作成されたディレクトリへ移動する。
```
cd sample-project
```

npmで定義されている各コマンドを実行し、正常に動作することを確認する。
```
npm run dev
npm run build
npm run start
npm run lint
```

### Tailwind CSS

Tailwind CSSなどをインストールし、初期化をする。
```
npm install --save-dev tailwindcss postcss autoprefixer
npx tailwindcss init -p
```

Tailwind CSSの設定を修正する。
```javascript
// tailwind.config.js
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
}
```

CSSを修正する。
```css
/* src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
```

ページを修正する。
```jsx
// src/pages/index.tsx
export default function Home() {
  return <h1 className="text-3xl font-bold underline">Hello world!</h1>
}
```

Next.jsを起動して読み込まれているか確認する。
```
npm run dev
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/57c7bb42-57ba-4030-7004-d3564441db7e.png)

### Prettier
Prettierやプラグインをインストールする。

```
npm install --save-dev --save-exact prettier
echo {}> .prettierrc.json
echo .next> .prettierignore

npm install --save-dev eslint-config-prettier
npm install --save-dev prettier-plugin-tailwindcss
```

.eslintrc.jsonを修正する。
```json
{
  "extends": [
    "next/core-web-vitals",
    "prettier"
  ]
}
```

### Storybook

Storybookのインストールと初期化する。
```
npx storybook init
```

Storybookのコマンドを実行して動作を確認する。
```
npm run storybook
npm run build-storybook
```

上記でエラーが発生する場合は、以下の記事の通りの現象の可能性ありなので対応する。
https://qiita.com/hiroaki-suzuki/items/8cdd0b2d1a032b8f9eb1

src/storiesは削除して代わりに、componentsディレクトリを作成する。
※これは好みだが、以下からは上記を前提に記述。

Tailwind CSSを利用したコンポーネントとストーリーをsrc/componentsに作成する。

例）
```jsx
// components/button/Button.tsx
import React from 'react'

export type ButtonProps = {
  children?: React.ReactNode
  className?: string
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
}

export const Button: React.FC<ButtonProps> = ({ children, className = '', onClick = () => {} }) => {
  return (
    <button className={`${className} rounded`.trim()} onClick={onClick}>
      {children}
    </button>
  )
}
```
```jsx
// components/button/Button.stories.tsx
import React from 'react'
import { ComponentMeta, ComponentStory } from '@storybook/react'
import { Button, ButtonProps } from './Button'

export default {
  title: 'Button',
  component: Button,
  argTypes: { onClick: { action: 'clicked' } },
} as ComponentMeta<typeof Button>

const Template: ComponentStory<typeof Button> = (args: ButtonProps) => <Button {...args} />

export const Primary = Template.bind({})
Primary.args = {
  children: 'Primary Button',
  className: 'bg-blue-600 text-white p-2',
}

export const Secondary = Template.bind({})
Secondary.args = {
  children: 'Secondary Button',
  className: 'bg-gray-300 text-black p-2',
}

export const Disabled = Template.bind({})
Disabled.args = {
  children: 'Disabled Button',
  className: 'bg-gray-200 text-gray-600 p-2 cursor-not-allowed',
}
```

StorybookにTailwind CSSを読み込ませるための記述と@エイリアスを有効にするための設定も追加する。
```javascript
// .storybook/main.js
const path = require('path')

module.exports = {
  typescript: {
    reactDocgen: 'react-docgen-typescript-plugin',
  },
  stories: [
    '../src/components/**/*.stories.mdx',
    '../src/components/**/*.stories.@(js|jsx|ts|tsx)'
  ],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions'
  ],
  framework: '@storybook/react',
  core: {
    builder: '@storybook/builder-webpack5',
  },
  webpackFinal: async (config) => {
    // パスの@エイリアスの解決
    config.resolve.alias = {
      ...config.resolve.alias,
      '@': path.resolve(__dirname, '../src'),
    }

    // Tailwindの読み込み解決
    config.module.rules.push({
      test: /\.css$/,
      use: [
        {
          loader: 'postcss-loader',
          options: {
            postcssOptions: {
              plugins: { tailwindcss: {}, autoprefixer: {} },
            },
          },
        },
      ],
      include: path.resolve(__dirname, '../'),
    })

    return config
  },
}
```
```javascript
// .storybook/preview.js
import '../src/styles/globals.css'

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}
```

Storybookを起動する。
```
npm run storybook
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/97c846b9-3c8f-97ea-eacc-0e38830184b4.png)

### Storybook関連のテスト

#### ビジュアルテスト
https://storybook.js.org/tutorials/ui-testing-handbook/react/en/visual-testing/

Chromaticにアクセスし登録などを行う。
https://www.chromatic.com/
https://www.chromatic.com/docs/setup?utm_source=storybook_website&utm_medium=link&utm_campaign=storybook

Chromaticをインストールする。
```
npm install --save-dev chromatic
```

.envを作成する。.envファイルは、.gitignoreへ追加しGit管理外にする。
```
CHROMATIC_PROJECT_TOKEN=Chromaticのproject-tokenを設定
```

コマンドを実行して確認する。
```
npx chromatic
```

![スクリーンショット 2023-03-26 23.36.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/29627c79-2fef-af53-9d2f-e67a5bc44ddb.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/0613db61-dc10-9618-3d23-f697ea8e5d1c.png)

#### Mock Service Worker
https://storybook.js.org/tutorials/ui-testing-handbook/react/en/composition-testing/
https://storybook.js.org/addons/msw-storybook-addon

APIリクエストをモック化してくれるライブラリをインストールする。
```
npm install --save-dev msw msw-storybook-addon
npx msw init public/
```

```javascript
// .storybook/preview.js
import '../src/styles/globals.css'
import { initialize, mswDecorator } from 'msw-storybook-addon'

initialize()

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}
export const decorators = [mswDecorator]
```

.storybook/main.jsに静的ディレクトリとして、publicディレクトリを追加
```javascript  
  staticDirs: ['../public'],
```

#### インタラクションテスト
https://storybook.js.org/tutorials/ui-testing-handbook/react/en/interaction-testing/

ライブラリをインストールする。
```
npm install --save-dev @storybook/jest @storybook/test-runner
```

featuresのブロックを追加する。
```javascript
// .storybook/main.js
const path = require('path')

module.exports = {
  typescript: {
    reactDocgen: 'react-docgen-typescript-plugin',
  },
  stories: ['../src/components/**/*.stories.mdx', '../src/components/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'],
  framework: '@storybook/react',
  core: {
    builder: '@storybook/builder-webpack5',
  },
  features: {
    interactionsDebugger: true,
  },
  webpackFinal: async (config) => {
    // パスの@エイリアスの解決
    config.resolve.alias = {
      ...config.resolve.alias,
      '@': path.resolve(__dirname, '../src'),
    }

    // Tailwindの読み込み解決
    config.module.rules.push({
      test: /\.css$/,
      use: [
        {
          loader: 'postcss-loader',
          options: {
            postcssOptions: {
              plugins: { tailwindcss: {}, autoprefixer: {} },
            },
          },
        },
      ],
      include: path.resolve(__dirname, '../'),
    })

    return config
  },
}
```

package.jsonにテストコマンドを追加する。
```
{
  "scripts": {
    "test-storybook": "test-storybook"
  }
}
```

コマンドを実行して動作しているか確認する。
```
npm run storybook
npm run test-storybook
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/ead9de8c-0329-baa5-1854-3384df717cdf.png)

※msw関連で、失敗することがある？要調査

#### アクセシビリティテスト

https://storybook.js.org/tutorials/ui-testing-handbook/react/en/accessibility-testing/

アクセシビリティテストのアドオンをインストールする。
```
npm install --save-dev @storybook/addon-a11y
```
アクセシビリティテストのアドオンを追加する。
```javascript
// .storybook/main.js
const path = require('path')

module.exports = {
  typescript: {
    reactDocgen: 'react-docgen-typescript-plugin',
  },
  stories: ['../src/components/**/*.stories.mdx', '../src/components/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',
  ],
  framework: '@storybook/react',
  core: {
    builder: '@storybook/builder-webpack5',
  },
  features: {
    interactionsDebugger: true,
  },
  webpackFinal: async (config) => {
    // パスの@エイリアスの解決
    config.resolve.alias = {
      ...config.resolve.alias,
      '@': path.resolve(__dirname, '../src'),
    }

    // Tailwindの読み込み解決
    config.module.rules.push({
      test: /\.css$/,
      use: [
        {
          loader: 'postcss-loader',
          options: {
            postcssOptions: {
              plugins: { tailwindcss: {}, autoprefixer: {} },
            },
          },
        },
      ],
      include: path.resolve(__dirname, '../'),
    })

    return config
  },
}
```

Storybookで確認する。
```
npm run storybook
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/f53cfe86-65b9-b31c-6e51-cdd6ad5c7fbf.png)


自動化するために以下をインストールし、テストランナーファイルを作成する。
```
npm install --save-dev axe-playwright
```
```javascript
// .storybook/test-runner.js
const { injectAxe, checkA11y } = require('axe-playwright')

module.exports = {
  async preRender(page, context) {
    await injectAxe(page)
  },
  async postRender(page, context) {
    await checkA11y(page, '#root', {
      detailedReport: true,
      detailedReportOptions: {
        html: true,
      },
    })
  },
}
```
コマンドを実行し確認する。
```
npm run storybook
npm run test-storybook
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/e46ef969-83d2-3491-71a8-1900e5908b55.png)

#### E2Eテスト
https://storybook.js.org/tutorials/ui-testing-handbook/react/en/user-flow-testing/

Cypressをインストールする。
```
npm install --save-dev cypress
```

package.jsonに実行コマンドを追加する。
```json
  "scripts": {
    "open-cypress": "cypress open",
    "run-cypress": "cypress run"
  }
```

cypress.config.tsを作成する。
```typescript
// cypress.config.ts
import { defineConfig } from 'cypress'

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000/',
    supportFile: false,
  },
})
```

テストファイルを作成する。
```javascript
// cypress/e2e/home.cy.ts
describe('Home Page', () => {
  it('Hello world', () => {
    cy.visit('/')
    cy.get('h1').should('have.text', 'Hello world!')
  })
})
```
コマンドを実行して確認する。
```
npm run dev
npm run open-cypress
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/31d79a75-e8d8-d6b5-8840-195974dcbfff.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/cc70f902-ba9d-d4a7-3378-6a2445cc82c6.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/3d6410bf-94cb-5da3-0bc2-9b63899731f4.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/284b9c84-08fa-2749-ec69-1e1a507991b6.png)

CLI実行についても確認する。
```
npm run run-cypress
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/3b0be4bc-e28d-1d6e-9250-70b85f0e09c6.png)


### GitHub ActionsでのUIテストの実行
GitHub ActionsでCIを構築する。

Chromaticのプロジェクトトークンをsecretsに登録する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/b7f172be-26b8-5119-f6af-b398442f0d2c.png)

Name: CHROMATIC_PROJECT_TOKEN
Secret: project-token
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/e885ec61-87e9-2cc6-2538-1dacc7c83d44.png)

1. NPMなどのライブラリキャッシュの作成
2. テスト実行（以下並行）
    1. インタラクションテストとアクセシビリティテストの実行
    1. Chromaticビジュアルテストの実行
    1. Cypressでのワークフローテストの実行

```yaml
#　.github/workflows/ui-tests.yml
name: 'UI Tests'

on: push

jobs:
  # ライブラリのインストールとキャッシュ
  install-cache:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
      - name: Cache npm dependencies and cypress
        uses: actions/cache@v3
        id: npm-cache
        with:
          path: |
            ~/.cache/Cypress
            node_modules
          key: ${{ runner.os }}-npm-ci-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-ci
      - name: Install dependencies if cache invalid
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm ci
  # インタラクションテストとアクセシビリティテストの実行
  interaction-and-accessibility:
    runs-on: ubuntu-latest
    needs: install-cache
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18.15.0'
      - name: Restore npm dependencies
        uses: actions/cache@v3
        id: npm-cache
        with:
          path: |
            ~/.cache/Cypress
            node_modules
          key: ${{ runner.os }}-npm-ci-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-ci
      - name: Install Playwright
        run: npx playwright install --with-deps
      - name: Build Storybook
        run: npm run build-storybook --quiet
      - name: Serve Storybook and run tests
        run: |
          npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \
          "npx http-server storybook-static --port 6006 --silent" \
          "npx wait-on tcp:127.0.0.1:6006 && npm run test-storybook"
  # Chromaticビジュアルテストの実行
  visual-and-composition:
    runs-on: ubuntu-latest
    needs: install-cache
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - name: Restore npm dependencies
        uses: actions/cache@v3
        id: npm-cache
        with:
          path: |
            ~/.cache/Cypress
            node_modules
          key: ${{ runner.os }}-npm-ci-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-ci
      - name: Publish to Chromatic
        uses: chromaui/action@v1
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
  # Cypressでのワークフローテストの実行
  user-flow:
    runs-on: ubuntu-latest
    needs: install-cache
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Restore npm dependencies
        uses: actions/cache@v3
        id: npm-cache
        with:
          path: |
            ~/.cache/Cypress
            node_modules
          key: ${{ runner.os }}-npm-ci-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-npm-ci
      - name: Next.js build
        run: npm run build
      - name: Cypress run
        uses: cypress-io/github-action@v5
        with:
          start: npm start
```

プッシュして実行を確認する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/00132261-27fd-1f37-c01c-260d3ec51d45.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/be19d240-31c4-38e7-8a40-085f2f497a37.png)

### Jest
コンポーネント以外の単体テストのためJestをインストール

```
npm install --save-dev jest @types/jest ts-jest
```

jest.config.jsを作成する
```javascript
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  coverageDirectory: '<rootDir>/coverage/jest',
}
```

package.jsonに実行コマンドを追加する。
```json
  "scripts": {
    "test": "jest"
  }
```

コマンドを実行して動作を確認する
```
npm run test
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/22558216-0f7c-f5c1-ea90-591e723ced76.png)

### カバレッジ
JestとStorybookのカバレッジを収集する

#### Jestのカバレッジ

jest.config.jsにcoverageDirectoryを追加する
```javascript
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  coverageDirectory: '<rootDir>/coverage/jest',
}
```

package.jsonに実行コマンドを追加する。
```json
  "scripts": {
    "test-coverage": "jest --coverage"
  }
```

コマンドを実行して動作を確認する
```
npm run test-coverage
```

カバレッジファイルを確認する
coverage/jest/lcov-report/index.html
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/44dcba9a-2947-8e09-3782-1933f038f3d2.png)

#### Storybookのカバレッジ
https://storybook.js.org/docs/react/writing-tests/test-coverage
https://storybook.js.org/addons/@storybook/addon-coverage

```
npm install --save-dev @storybook/addon-coverage
```

main.jsのアドオンにカバレッジのアドオンを追加する
```javascript
// .storybook/main.js
const path = require('path')

module.exports = {
  typescript: {
    reactDocgen: 'react-docgen-typescript-plugin',
  },
  stories: ['../src/components/**/*.stories.mdx', '../src/components/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',
    '@storybook/addon-coverage',
  ],
  // ....
```

package.jsonに実行コマンドを追加する。
```json
  "scripts": {
    "test-storybook-coverage": "test-storybook --coverage && npx nyc report --reporter=lcov -t coverage/storybook --report-dir coverage/storybook",
  }
```

コマンドを実行して動作を確認する
```
npm run storybook
npm run test-storybook-coverage
```

カバレッジファイルを確認する
coverage/storybook/lcov-report/index.html
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/d0566200-babd-22a8-6103-07986d97f814.png)

#### JestとStorybookのカバレッジをマージ

package.jsonに実行コマンドを追加する。
```json
  "scripts": {
    "test-merge-coverage": "npx istanbul-merge --out coverage/merged/coverage.json ./coverage/jest/coverage-final.json ./coverage/storybook/coverage-storybook.json",
    "coverage-report": "npx nyc report --reporter=lcov -t coverage/merged --report-dir coverage/merged"
  }
```

コマンドを実行して動作を確認する
```
npm run test-merge-coverage
npm run coverage-report
```

カバレッジファイルを確認する
coverage/merged/lcov-report/index.html
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44066/d61cf136-d60e-5707-b9eb-41c78465899f.png)