Laravel 6 のプロジェクトに Vue.js & TypeScript & ESLint & Jest をもりっと導入する

# はじめに

株式会社じげんの橋濱です。
結婚相談所の比較サービス「結婚相談所比較ネット」を開発しています。

Vue.js & TypeScript でチャットボット風のフォーム作り、これを機に JS 周りの保守性を高めるため ESLint & Jest を導入しました。
導入するにあたっていくつか詰まってしまったところがあったので、手順や設定ファイルの状態を残しておきたいと思います。

IE 対応のため、今回導入した Vue.js は2系なのでその点あらかじめご承知おきください。


# まずは Vue.js を使えるように

それでは、はじめに Vue.js から導入していきます。

## Laravel Mix をバージョン6へ

当サービスでは IE 対応が必要だったため、Laravel Mix Polyfill 最新版の前提として Laravel Mix 6 へアップグレードしました。
また、Vue.js 3 系を使いたい場合も Laravel Mix 6 が入っていることが前提となります。

```zsh:ターミナル
npm i laravel-mix@^6.0
```

NPM Scripts （ `npm run` コマンド）も Laravel Mix 6 での記法に合わせて書き換えましょう。

参照: [Upgrade to Mix 6 \| Laravel Mix Documentation](https://laravel-mix.com/docs/6.0/upgrade#update-your-npm-scripts)

```json:package.json
"scripts": {
  "development": "mix",
  "watch": "mix watch",
  "watch-poll": "mix watch -- --watch-options-poll=1000",
  "hot": "mix watch --hot",
  "production": "mix --production"
},
```

`webpack.mix.js` 内で `path` を使っている場合、明示的にロードしないと `path is not defined` エラーが出るようになったため、以下を冒頭に加えます。

参照: [path is not defined when using mix\.webpackConfig · Issue \#503 · laravel\-mix/laravel\-mix](https://github.com/laravel-mix/laravel-mix/issues/503)

```js:webpack.mix.js
const path = require('path');
```

### Sass を使っている場合

Sass を使っている場合、この時点でもまだ Laravel Mix が起動できないかもしれません。
その場合、一度 `sass` `sass-loader` をアンインストールしてから `npm run dev` を実行し、Laravel Mix から自動インストールします。

参照: [\[6\.0\.0\-beta\.14\] \[webpack\-cli\] Invalid configuration object\. · Issue \#2633 · laravel\-mix/laravel\-mix](https://github.com/laravel-mix/laravel-mix/issues/2633)

```zsh:ターミナル
npm un sass sass-loader # アンインストール
npm run dev # sass sass-loader が Laravel Mix により自動でインストールされる
```


### 導入ライブラリの状況によって必要な対応

これに加えて、プロダクトで導入しているライブラリの状況によるとは思いますが、私の場合は以下を対応する必要がありました。


#### Laravel Mix 6 を入れる前

```zsh:ターミナル
# Stylelint 周りのライブラリのアップグレード
npm i stylelint@^14.0.1 stylelint-order@^5.0.0
```


#### Laravel Mix 6 を入れた後

アセットトランスパイル時のキャッシュを作るために `hard-source-webpack-plugin` を入れていたのですが、Laravel Mix 6 に含まれる Webpack 5 の Persistent Caching で対応されたためお役御免となりました。

```zsh:ターミナル
# webpack: command not found を解消
npm i stylelint-webpack-plugin@^3.1.0

# Cannot read property 'tap' of undefined を解消
npm un hard-source-webpack-plugin
```

また、`stylelint-webpack-plugin` のアップグレードによりオプションの記法が変わったため `postcss-scss` を導入して対応しました。

```zsh:ターミナル
npm i postcss-scss@^4.0.2
```

```diff:webpack.mix.js
new StylelintPlugin({
+ customSyntax: 'postcss-scss'
- syntax: 'scss'
}),
```


## Vue.js をトランスパイルするための記述を追加

```js:webpack.mix.js
mix.vue({ version: 2 }); // Vue.js 2系の場合は version オプションを指定
```

最後に、もし `vue` が `package.json` になければインストールします。
（上述した `sass` `sass-loader` のように、もしかしたら Laravel Mix 起動時に自動でインストールしてくれるかも）

※ Laravel 6 から Vue.js がデフォルトでインストールされなくなったため

```zsh:ターミナル
npm i vue vue-loader vue-template-compiler
```

これで Vue.js の導入が完了です。


# TypeScript で Vue.js を書くために

TypeScript のライブラリを導入します。

```zsh:ターミナル
npm i typescript ts-loader
```

```jsonc:tsconfig.json
{
  "include": ["resources/ts/**/*"],
  "compilerOptions": {
    "target": "es2021",
    "lib": ["dom", "es2021"],
    "moduleResolution": "node",
    "sourceMap": true,
    "strict": true,
    "noUnusedLocals": true,
    "experimentalDecorators": true,
    "baseUrl": "./",
    "paths": {
      "@": ["resources/ts"],
      "@/*": ["resources/ts/*"]
    }
  }
}
```


## `.vue` ファイル用の型定義

Vue ファイル内の `import` で子コンポーネントを読み込めるように、型定義ファイルを作成します。

```resources/ts/@types/shims-vue.d.ts
declare module '*.vue' { // .vue ファイルの型定義
  import Vue from 'vue';
  export default Vue;
}
```

```jsonc:tsconfig.json
"paths": {
  // たとえば上のように resources/ts/@types/ 以下へ型定義ファイルを設置する場合、
  // Laravel Mix がそれを検知できるようにしておく
  "*": ["resources/ts/@types/*"],
  // ...
}
```

ここまでで TypeScript の導入は完了です。


# ESLint に構文をチェックさせる

以下を導入します。

```zsh:ターミナル
npm i @typescript-eslint/parser eslint eslint-plugin-vue vue-eslint-parser
```

```jsonc:.eslintrc
{
  "env": {
    "browser": true,
    "es2021": true,
    "node": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:vue/recommended"
  ],
  "parserOptions": {
    "parser": "@typescript-eslint/parser",
    "ecmaVersion": 2021,
    "sourceType": "module"
  }
}
```

アセットのビルド中に構文チェックをしたい場合は `eslint-webpack-plugin` も導入します。

```zsh:ターミナル
npm i eslint-webpack-plugin
```

```js:webpack.mix.js
// ...
const EslintPlugin = require('eslint-webpack-plugin');

const webpackConfig = {
  // ...
  plugins: [
    new EslintPlugin[{
      extensions: ['vue', 'ts', 'js'],
      exclude: ['node_modules', 'vendor']
    }]
  ]
};

mix.webpackConfig(webpackConfig);
```

最後に、NPM Scripts から実行できるようにします。

```jsonc:package.json
// webpack.mix.js もついでに構文チェック
"lint:ts": "eslint --ext .vue,.ts,.js --fix resources/{vue,ts}/ webpack.mix.js",
```


## ESLint のルール設定

これで ESLint の導入自体は完了ですが、参考までに比較ネットではこんな感じでルール設定しました。

```jsonc:.eslintrc
"rules": {
  "semi": ["error", "always"], // セミコロン必須。PHP の文法と統一
  "semi-spacing": ["error", { "after": true, "before": false }], // セミコロンは分の直後
  "semi-style": ["error", "last"], // セミコロンはいつも行末へ
  "no-extra-semi": "error", // 二重で付いてたり等の、不要なセミコロンを禁止
  "no-unexpected-multiline": "error", // 紛らわしい複数行コードの禁止
  "no-unreachable": "error", // 到達不能コードの禁止
  "operator-linebreak": ["error", "before"], // 改行は演算子の前で行う
  "quotes": ["error", "single", { "avoidEscape": true }], // 基本はシングルクォーテーション
  "max-len": ["error", { "code": 120, "tabWidth": 2 }], // PHPCS に合わせて120文字を最長の文字数に
  "indent": ["error", 2], // インデントを2に
  "comma-dangle": ["error", "always-multiline"], // 複数行の場合はいつもトレイリングカンマを付ける
  "curly": "error", // 波括弧なしの if 文を禁止
  "no-undef": "off", // eslint-plugin-vue により props のデフォルト値が必須なため undefined を使えるよう対応

  "vue/html-indent": ["error", 2, { "baseIndent": 1 }], // <template> 内のベースインデントレベルを1に
  "vue/script-indent": ["error", 2, { "baseIndent": 1, "switchCase": 1 }] // <script> 内のベースインデントレベルを1に
},
"overrides": [ // indent ルールと vue/script-indent ルールが衝突するため、ここで回避
  {
    "files": ["*.vue"],
    "rules": {
      "indent": "off"
    }
  }
]
```


# Jest でコンポーネントテストを始めたい

関連ライブラリを導入します。

参照: [vue\-test\-utils \| Vue Test Utils](https://vue-test-utils.vuejs.org/ja/)

```zsh:ターミナル
npm i jest vue-jest @vue/test-utils
```

```js:jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  moduleNameMapper: { // テストファイル内で import 時のエイリアス（ここでは `@/` ）を使うのに必要
    '^@/(.*)$': '<rootDir>/resources/$1',
  },
  moduleFileExtensions: ['vue', 'ts', 'js', 'json'],
  transform: {
    '^.+\\.vue$': 'vue-jest',
    '^.+\\.ts$': 'ts-jest',
    '^.+\\.js$': 'babel-jest',
  },
  coverageDirectory: '<rootDir>/resources/coverage', // ルートには PHPUnit の tests/ があり混同しそうなため、resources/ 下へ
  transformIgnorePatterns: [ // @vue/test-utils を使うために必要
    'node_modules/(?!@ngrx|(?!deck.gl)|ng-dynamic)',
  ],
  collectCoverage: true,
  collectCoverageFrom: [ // どの階層のファイルからカバレッジを計算するか
    '<rootDir>/resources/vue/components/**/*.vue',
  ],
};
```

コンポーネントファイルをテストしたい場合、 `import` `export` などの ES Modules 構文が Jest で解釈できないため、上で書いたように `babel-jest` を使います。

Babel の設定ファイルも追加します。
ちなみに Babel 自体は Laravel Mix の依存ライブラリなので、あえてインストールする必要はありません。

```js:babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: { node: 'current' },
      },
    ],
  ],
};
```

NPM Scripts に登録します。
ちなみに `test` のコマンドだけは `npm run test` でなく `npm test` でも実行できます。


## Vuex を使っている場合の書き方

コンポーネント中に Vuex を使っている場合、テスト用の Vue クラスに Vuex プラグインを追加して、wrapper 作成時に含める必要があります。

> `Wrapper` は、マウントされたコンポーネントと仮想 DOM 、またはコンポーネントと仮想 DOM をテストするメソッドを含むオブジェクトです。

参照: [Wrapper \| Vue Test Utils](https://vue-test-utils.vuejs.org/ja/api/wrapper/)

```AnswerHoge.spec.ts
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import store from '../../../../../../store/hoge';

// ...

const localVue = createLocalVue(); // テスト用の Vue コンポーネントを作る
localVue.use(Vuex);

let wrapper: any;

beforeEach(() => { // 各テストごとに wrapper を作る
  wrapper = mount(AnswerHoge, { localVue, store });
});

describe('AnswerHoge', () => {
  test('回答後、回答がグローバルステートと一致しているか', () => {
    wrapper.vm.processAnswer(3); // コンポーネントのメソッドやデータは wrapper.vm から生やす形で参照
    expect(wrapper.vm.$store.state.answer.hoge).toBe(3);
  });
});
```


# まとめ

当サービスはこれまで Blade ＆バニラ JS なフロントエンド構成だったため、今回の導入で一気にモダン化しました。
キャッチアップを頑張っていきます！