Vue.js3(Vuex) + Jest を試してみた

## 概要
Vue.js 3(Vuex) + Jest + Cypress を試してみた。主にテストを試すことが目的でした。
テストの一部は、英語記事を洗っても見つからなかったものなので、記事を書いた時点では貴重かもしれない。

### 環境
比較的新しい構成です。

* Windows 10
* Node.js ... v14.16.0
* Vue.js ... TypeScript
  * @vue/cli ... 4.5.13 (global install)
  * vue ... 3.1.4
  * vuex ... 4.0.2
  * @vue/test-utils ... 2.0.0-rc.10
  * vue-jest ... 5.0.0-alpha.10

### 評価
* Cypress
   * GUI前提で WSL では動かすのが面倒でやめた
   * スクリプトの各行でスクリーンショットが記録されるのはとてもデバッグで便利でChrome限定でも e2e テストをとても楽にしている印象
* Vue.js 3
  * TypeScript で Vuex を使う場合 `$state` プロパティを使うやり方と、 Composition API のやり方に沿った方式がある
  * 後者の方はJest経由でテストする事例がない (今回何とか動いたのを載せている)
  * mutation, actions の単体テストは両方とも同じやり方でできる

## プロジェクト作成

1. `@vue/cli` のインストール

    ```
    npm install -g @vue/cli
    ```

2. プロジェクト作成 +
   `Use class-style component syntax` を **Y** にする事例が多いのですが、今後は class-style でない `defineComponent` の宣言がメインらしいです。Babel は使うようにしないと、テストで失敗します。

    ```
    > vue create vue3-counter-server-jest-cypress
    
    
    Vue CLI v4.5.13
    ? Please pick a preset: Manually select features
    ? Check the features needed for your project: Choose Vue version, TS, Router, Vuex, CSS Pre-processors, Linter, Unit, E2E
    ? Choose a version of Vue.js that you want to start the project with 3.x
    ? Use class-style component syntax? No
    ? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? No
    ? Use history mode for router? (Requires proper server setup for index fallback in production) Yes
    ? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
    ? Pick a linter / formatter config: Standard
    ? Pick additional lint features: Lint on save
    ? Pick a unit testing solution: Jest
    ? Pick an E2E testing solution: Cypress
    ? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
    ? Save this as a preset for future projects? No
    ...
    ```

## ソース一式
よくあるカウンターアプリ。

* https://github.com/unmyr/vue3-counter-server
* 画面イメージ(+1 ボタンが同期, -1ボタンが500ms後にカウントアップ)

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326797/58dd47e3-7a68-e3b7-f30b-6561880a1973.png)

### $state property
Vue.js 2 からのやり方。簡単にいくかと思いきや `$store` の型の定義やらで、そのまま移行はできない。

#### src/component/CounterWithVuex.vue

```vuejs
<template>
  <div class="counter-with-vuex">
    <button name="plus" :disabled="isLoading"
     v-on:click="syncIncrement(step)"
    >+{{ step }}</button>
    <button name="minus" :disabled="isLoading"
     v-on:click="asyncIncrement(step * -1)"
    >-{{ step }}</button>
    <div v-if="isLoading">Now loading...</div>
    <div v-else id="count-view" class="count-area">{{ count }}</div>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  name: 'CounterWithVuex',
  props: {
    step: {
      type: Number,
      required: true
    }
  },
  computed: {
    count (): number {
      return this.$store.state.count
    },
    isLoading (): boolean {
      return this.$store.state.isLoading
    }
  },
  methods: {
    syncIncrement (num: number) {
      this.$store.dispatch('increment', num)
    },
    asyncIncrement (num: number) {
      this.$store.dispatch('increment', num)
    }
  }
})
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.count-area {color:blueviolet}
</style>
```

#### src/shims-vue.d.ts

`$state` の型を定義してやる必要がある。

```diff
@@ -1,6 +1,18 @@
 /* eslint-disable */
 declare module '*.vue' {
   import type { DefineComponent } from 'vue'
   const component: DefineComponent<{}, {}, any>
   export default component
 }
+
+import { ComponentCustomProperties } from 'vue'
+import { Store } from 'vuex'
+
+declare module '@vue/runtime-core' {
+  // Declare your own store states.
+  import { IState } from '@/store/IState'
+
+  interface ComponentCustomProperties {
+    $store: Store<IState>
+  }
+}
```

#### src/index.ts
App.vue やら About.vue やらで `TS7016: Could not find a declaration file for module` と怒られたので追加。

```typescript
declare module "*.vue" {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}
```

### Composition API
書き方が分れば、 `$store` プロパティの方と比べて小細工はいらない。

#### src/main.ts

`use(store, key)` を追加している。key自体は、画面コンポーネントの方でも使う。

```typescript
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { store, key } from './store'

createApp(App).use(store, key).use(router).mount('#app')
```

#### src/component/CounterWithVuex.vue

```vuejs
<template>
  <div class="counter-with-vuex">
    <button name="plus" :disabled="isLoading"
     v-on:click="syncIncrement(step)"
    >+{{ step }}</button>
    <button name="minus" :disabled="isLoading"
     v-on:click="asyncIncrement(step * -1)"
    >-{{ step }}</button>
    <div v-if="isLoading">Now loading...</div>
    <div v-else id="count-view" class="count-area">{{ count }}</div>
  </div>
</template>

<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useStore } from 'vuex'
import { key } from '@/store'

export default defineComponent({
  name: 'CounterWithVuex',
  setup () {
    const store = useStore(key)

    return {
      // access a state in computed function
      count: computed(() => store.state.count),
      isLoading: computed(() => store.state.isLoading),
      // access a mutation
      syncIncrement: (num: number) => store.commit('increment', num),
      // access a action
      asyncIncrement: (num: number) => store.dispatch('increment', num)
    }
  },
  props: {
    step: {
      type: Number,
      required: true
    }
  }
})
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
.count-area {color:blueviolet}
</style>
```

## テスト
Jest を使った。何となく。
mutation と actions の単体テストを行う場合は、ファイル分割をするのがポイントのようだ。気になる人は、Gitの方を見てもらえばよい。

とはいっても actions のほうは、処理の待ち合わせのコードを書いたり、多少手間はかかる。(単体テストできないともっと手間なので、有難みは強い。)

### $state property
こっちはドキュメント通りいける。

* [Testing Vuex | Vue Test Utils for Vue 3 (2.0.0-rc.10)](https://next.vue-test-utils.vuejs.org/guide/advanced/vuex.html#testing-with-a-real-vuex-store)

ポイントは localVue が使えなくなっているのと、 `global` > `plugins` で指定することぐらい。

```javascript
  // ... snip ...
  it('should render increment', async () => {
    const state = {
      count: 0,
      isLoading: false
    }
    const store = createStore({
      state,
      mutations,
      actions: mockActions
    })
    const step = 1
    const wrapper = mount(CounterWithVuex, {
      global: {
        plugins: [store]
      },
      props: { step }
    })
    expect(state.count).toBe(0)
    await wrapper.find("button[name='plus']").trigger('click')
    expect(state.count).toBe(1)
    expect(state.isLoading).toBe(false)
  })
  // ...snip...
```

### Composition API

これは、試行錯誤して次のやり方に落ち着いた。`global` > `plugins` で指定する。
`src/component/CounterWithVuex.vue` の `const store = useStore(key)` に該当する処理で key から store のデータを取り出す操作に該当する。
この中身が createStore の出力とも違うので組み立てている。
`commit` や `dispatch` を自前で実装とか、かなりトリッキー。

なお actions 自体はモックにしている。
本物を使っても良いが、サーバーの用意やDBにテストデータを突っ込む手間を考えると、モックやらspyやらでダミー応答させた方が画面テストは楽だと思う。

```javascript
// ...snip...

describe('CounterWithVuex.vue', () => {
  const mockActions = {
    increment ({ commit }, param) {
      commit('increment', param)
      commit('setLoading', false)
    }
  }

  it('should render increment', async () => {
    const state = {
      count: 0,
      isLoading: false
    }
    const commit = (fname, payload) => {
      mutations[fname](state, payload)
    }
    const dispatch = (fname, payload) => {
      mockActions[fname]({ commit }, payload)
    }

    const step = 1
    const wrapper = mount(CounterWithVuex, {
      global: {
        provide: {
          [key]: {
            state,
            mutations,
            actions: mockActions,
            commit: commit,
            dispatch: dispatch
          }
        }
      },
      props: { step }
    })
    expect(state.count).toBe(0)
    await wrapper.find("button[name='plus']").trigger('click')
    expect(state.count).toBe(1)
    expect(state.isLoading).toBe(false)
  })
  // ...snip...
```

## トラブルシューティング

### TS2339: Property '$store' does not exist on type 'ComponentPublicInstance...

`npm build` や `npm run serve` で失敗する。

```
TS2339: Property '$store' does not exist on type 'ComponentPublicInstance<Readonly<{ step?: unknown; } & { step: number; } & {}>, {}, {}, { count(): number; isLoading(): boolean; }, { syncIncrement(num: number): void; asyncIncrement(num: number): void; }, ... 4 more ..., ComponentOptionsBase<...>>'.
    25 |   computed: {
    26 |     count (): number {
  > 27 |       return this.$store.state.count
       |                   ^^^^^^
```

**対処**

src/shims-vue.d.ts に `$store` の型指定を追加する。

### TS7016: Could not find a declaration file for module './App.vue'

`npm build` や `npm run serve` で失敗する。

```
TS7016: Could not find a declaration file for module './App.vue'. '.../vue3-counter-server-jest-cypress/src/App.vue.js' implicitly has an 'any' type.
    1 | import { createApp } from 'vue'
  > 2 | import App from './App.vue'
      |                 ^^^^^^^^^^^
    3 | import router from './router'
    4 | import { store, key } from './store'
    5 |

 error  in src/router/index.ts:21:61
```

**対処**

src/index.d.ts にモジュール宣言を追加する。

```typescript
declare module "*.vue" {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}
```

## 参考

* [公式リリースされた Vuex 4 を使ってVue Composition API で状態管理する](https://zenn.dev/okakyo/articles/87faf411c4766e5ccb4a)
* [Vuex4 を Composition API + TypeScript で入門する](https://zenn.dev/ryo_kawamata/articles/intoroduce-vuex4-with-composition-api)
* [What is Vuex? | Vuex](https://next.vuex.vuejs.org/)
 * [TypeScript Support | Vuex (next.vuex.vuejs.org)](https://next.vuex.vuejs.org/guide/typescript-support.html#typing-usestore-composition-function)
 * [Testing | Vuex](https://vuex.vuejs.org/guide/testing.html)