Nuxt.js でクリック後に処理が完了するまで再度ボタンが押せなくなるボタンコンポーネントを開発する with 単体テスト

# この記事の目的

- ボタンを押したときに API 呼び出しを実行するケースを想定する。 この時、この時にAPI呼び出しが終了するまで再度ボタンを押されたくない、といった制御を行いたい。 これを実現するための Nuxt.js Component を作成する
- コンポーネントを作成して、これに Jest によるユニットテストを実施したい。 どのような手順でユニットテストを記載できるのかをコンポーネントの作成と合わせてみていく


## 実施バージョン

- vue: 2.6.12
- nuxt.js: 2.15.3
- yarn: 1.22.10
- jest: 26.6.3
- vue-jest: 3.0.4

また、コンポーネントボタンに Vuetify の `v-btn` を用いているので、

- vuetify: 2.4.2
- @nuxtjs/vuetify: 1.11.3

も併せて利用している。


# Component の作り方

`npx create-nuxt-app` で生成されたディレクトリ内の `components` 内に `vue` ファイルを作成する。 `create-nuxt-app` で作成した場合は、ここに `Logo.vue` が入っているはず。
ここでは、`~/components/ui/CustomButton.vue` としてファイルを作成した。

`Logo.vue` をある程度参考にできるわけだが、コンポーネントを作る中でいくつか疑問が出てきたので、その内容と解決策を述べる。


## Component の利用方法

以下では省略するが、コンポーネントの呼び出し側のvueファイルでは以下のような定義で Component を登録済にしているとする。　これで、同 vue ファイル内で `custom-button` として利用可能になる。 補足ではあるが、[components への登録がパスカルケースにも関わらず、タグがケバブケースで使える理由についてはこちらを参照](https://qiita.com/t-kigi/items/fcf000c21523d0ea1269)。

```vue:コンポーネント呼び出し側
f<script>
import CustomButton from '~/components/ui/CustomButton.vue';

export default {
  components: {
    CustomButton,
  },
  // ... 後略
}
</script>
```


## コンポーネントに親から属性を渡す

今回作りたい構成のイメージは以下のような感じになる。
ボタンのラベルを自由に設定できて、ボタンがクリックされるときに実行する関数を渡せるような感じ。

```vue
<template>
  <button onclick="(ここにクリックしたときに実行させたいイベントが入る)">
    {{ ここにラベルが入る }}
  </button>
</template>
```

### slot: タグの内容を渡す

まずは「ラベル」の部分（つまり、タグに挟まれている部分）をどのように渡すのかから見ていくと、これを実現するにはスロットを使う。

https://jp.vuejs.org/v2/guide/components-slots.html

 これは挿入したい場所に `<slot></slot>` を書いておけばよい。

```vue:~/components/ui/CustomButton.jsの途中
<template>
  <button onclick="(ここにクリックしたときに実行させたいイベントが入る)">
    <slot></slot>
  </button>
</template>
```
```vue:呼び出し側
<template>
  <!-- 前略 -->
  <custom-button>Component Button</custom-button>
  <!-- 後略 -->
</template>
```

これで、ボタンのラベルに "Component Button" が表示される。
ここでは１つの値のみを渡したが、テンプレートによっては複数の slot を渡したい場合もある。Vueの2.6以降なので名前付きスロットが利用できる。 名前付き slot を呼び出すには `<template v-slot:[slot名]>` を利用する。 

```vue:~/components/ui/CustomButton.jsの途中
<template>
  <button onclick="(ここにクリックしたときに実行させたいイベントが入る)">
    <slot name="front"></slot>
    +++ 
    <slot name="back"></slot>
  </button>
</template>
```

```vue:呼び出し側
<template>
  <!-- 前略 -->
  <custom-button>
    <template v-slot:front>Before</template>
    <template v-slot:back>After</template>
  </custom-button>
  <!-- 後略 -->
</template>
```

これでボタンのラベルには "Before +++ After" が表示される。


### props: タグの属性で値を渡す

コンポーネントの利用者側で準備している変数や関数をコンポーネントに渡して利用したい場合、プロパティを用いる。 コンポーネント側に `props` を定義することで、プロパティを渡すことができる。

https://jp.vuejs.org/v2/guide/components-props.html

ここでは、ボタンを押したときに呼び出したい関数を渡すこととする。 このような場合、以下のようにして `clickCallback` で関数を渡せるようにした。 色々と短縮形はあるが、`type` + `required: true` か `type` + `default` (`required` が `false` の時) は最低限書くようにしたい。

```vue:~/components/ui/CustomButton.jsの途中
<script>
export default {
  props: {
    clickCallback: {
      type: Function,
      required: false,
      default: () => {},
    }
  },
  // ... 後略
}
</script>
```

```vue:呼び出し側
<template>
  <!-- 前略 -->
  <custom-button :clickCallback="clickFunction">
    Component Button
  </custom-button>
  <!-- 後略 -->
</template>

<script>
import CustomButton from '~/components/ui/CustomButton.vue';

export default {
  components: {
    CustomButton,
  },
  methods: {
    clickFunction() {
      // ボタンが押されたときの挙動を記載
    },
  },
};
</script>
```

これで、呼び出し側で定義した関数を Component に渡すことができた。


## 渡された非同期関数が終了するまでの間ボタンが押せなくなるボタンコンポーネントの実装

どのような処理が行われるかが分からないので、clickCallback は Promise を返す関数 (つまり、await で処理が終了するまで待つような関数) を受け取るものとする。
あとは、この関数を呼び出す前後で内部に持つ変数を適時変化させてやればよい。 具体的なコードは以下の通りで、

- ボタンを押すとコンポーネント内に定義された click 関数が呼び出される
- click 関数の最初で `disabled = true` となり、ボタンが押せなくなる
- `await this.clickCallback();` で callback 用に渡された関数を呼び出し、Promise が完了するまで待つ
- その後、`disabled = false` となり、ボタンが押せるようになる

という流れを処理できる。


```vue:~/components/ui/CustomButton.js
<template>
  <v-btn :color="color" :block="block" :disabled="disabled" @click="click">
    <slot></slot>
  </v-btn>
</template>

<script>
export default {
  props: {
    clickCallback: {
      type: Function,
      default: () => {},
    },
    color: {
      type: String,
      default: 'success',
    },
    block: {
      type: Boolean,
      default: true,
    },
  },
  data() {
    return {
      disabled: false,
    };
  },
  methods: {
    async click() {
      this.disabled = true;
      try {
        await this.clickCallback();
      } catch (error) {
        console.log(error);
      }
      this.disabled = false;
    },
  },
};
</script>
```

```vue:呼び出し側
<template>
  <!-- 前略 -->
  <custom-button color="info" :clickCallback="click">
    Component Button
  </custom-button>
  <!-- 後略 -->
</template>

<script>
import CustomButton from '~/components/ui/CustomButton.vue';

export default {
  components: {
    CustomButton,
  },
  methods: {
    async click() {
      try {
        // 外部API呼び出し
        await this.$axios.get('...');
      } catch (error) {
        console.log(error);
      }
    },
  },
};
</script>
```

上記の動きを動画にしてみると以下の通り。 この例では sleep して5秒ぐらい返ってこない API を呼び出しているが、その間、ボタンが押せなくなっていることが確認できる。

![ice_video_20210323-173646.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/369f064f-5181-487d-9fbe-99b237c137c9.gif)


## Jest によるコンポーネントの単体テスト

ただ作るだけなら以上で終了だが、Jest によってコンポーネントをテストする方法もこれを例にして学んでみる。
正直なところ、バックエンド側や関数に対して単体テストをしようとは思うが、UIに対してどのように単体テストを行うんだ？ という疑問が最初はあった。
しかし、Vue の単体テストフレームワークを使うことで、**Vue コンポーネントをでマウント(仮想描画) してくれ、イベントの発火も行うことができる**。 そのため、単体テスト可能なレベルの複雑さにコンポーネントを適切に分割できれば、ちゃんと単体テストを書いて確かめることができる。
単体テストでは「コンポーネントにこういった入力・条件・操作をしたとき、そのコンポーネントはどういう状態になるか」を検証できるので、コンポーネントを一つの（ミュータブルな）クラスであると考え、単体テストを実施すると分かりやすいかもしれない。

以下では `npx create-nuxt-app` の中の CLI 設定で `Jest + Vuetify` をインストールしているものとして話を進める。

### 最初のテスト: Vuetify用単体テストの設定とコンポーネントの存在チェック

テンプレートで作られた `test` ディレクトリの中には `Logo.spec.js` ファイルが存在する。

```javascript:test/Logo.spec.js 
import { mount } from '@vue/test-utils'
import Logo from '@/components/Logo.vue'

describe('Logo', () => {
  test('is a Vue instance', () => {
    const wrapper = mount(Logo)
    expect(wrapper.vm).toBeTruthy()
  })
})
```

まずは、これと同じテストを今回作った `CustomButton.vue` 用に作成する。 ファイル名は `~/test/components/ui/CustomButton.spec.js` とした。 Vuetify 用の設定があるので、このページを参考にしつつ設定する。

https://vuetifyjs.com/en/getting-started/unit-testing/#bootstrapping-vuetify

```javascript:~/test/components/ui/CustomButton.spec.js
import { mount, createLocalVue } from '@vue/test-utils';
import Vuetify from 'vuetify';

import CustomButton from '~/components/ui/CustomButton.vue';

describe('CustomButton', () => {
  const localVue = createLocalVue();
  let vuetify;

  beforeEach(() => {
    vuetify = new Vuetify();
  });

  test('is a Vue instance', () => {
    const wrapper = mount(CustomButton, {
      localVue,
      vuetify,
    });
    expect(wrapper.vm).toBeTruthy();
  });
});
```

しかし、これだけで `yarn test` を実施すると、以下の通りエラーとなる。

```bash
$ jest
 PASS  test/components/ui/CustomButton.spec.js
  CustomButton
    ✓ is a Vue instance (37 ms)

  console.error
    [Vue warn]: Unknown custom element: <v-btn> - did you register the component correctly? For recursive components, make sure to provide the "name" option.
    
    found in
    
    ---> <Anonymous>
           <Root>

      at warn (node_modules/vue/dist/vue.common.dev.js:630:15)
      at createElm (node_modules/vue/dist/vue.common.dev.js:5929:11)
      at VueComponent.patch [as __patch__] (node_modules/vue/dist/vue.common.dev.js:6466:7)
      at VueComponent.Vue._update (node_modules/vue/dist/vue.common.dev.js:3942:19)
      at VueComponent.updateComponent (node_modules/vue/dist/vue.common.dev.js:4063:10)
      at Watcher.get (node_modules/vue/dist/vue.common.dev.js:4474:25)
      at new Watcher (node_modules/vue/dist/vue.common.dev.js:4463:12)
```

このエラーが出るのは元の Vuetify Unit Testing のページをちゃんとよんでいないからなのだが、以下のページ(とUnit Testingのページ)で説明されている通り、

- Bootstrapping Vuetify に従ったセットアップスクリプトを設置する
- これを `jest.config.js` で読み込むように設定する

といった作業が必要になる。

https://uyamazak.hatenablog.com/entry/2020/01/30/134757

ここでは、`test/unit/setup.js` に以下のファイルを作り、`jest.config.js` に以下の追記を行った。

```javascript:test/unit/setup.js
import Vue from 'vue'
import Vuetify from 'vuetify'

Vue.use(Vuetify);
```

```javascript:jest.config.js
module.exports = {
  // 省略
  setupFiles: ['./test/unit/setup.js'],
};
```

これを追加したのち、`yarn test` を実行すると最初のテストをパスできる。

```bash
$ yarn test
yarn run v1.22.10
$ jest
 PASS  test/components/ui/CustomButton.spec.js
  CustomButton
    ✓ is a Vue instance (133 ms)

(... 中略 ...)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.114 s
Ran all test suites.
Done in 2.72s.
```

### コンポーネントのテストの追加

実際にコンポーネントを使う想定でいくつかのテストを追加した。
今回追加したケースでは、それぞれ以下の項目をテストした。

- props, slot で渡した値がレンダリングにきちんと反映されるか
- click イベントが発生している最中、および前後で disabled の値がどのようになっているか
- clickCallback で例外が発生する場合はどうなるか

どのような項目が単体テストで取得でき、また、どのように検証できるかは以下のリファレンスを参考にした。

- https://vue-test-utils.vuejs.org/ja/api/wrapper/
- https://vue-test-utils.vuejs.org/ja/api/options.html
- https://jestjs.io/ja/docs/expect

補足として、一般的な Vue の単体テストでは mount でなく shallowMount を使った方がよいと書かれているが、`v-btn` という別のコンポーネントを内蔵するテンプレートをテストするため、ここでは `mount` を使って再帰的にマウントを行っている。

```javascript:~/test/components/ui/CustomButton.spec.js
import { mount, createLocalVue } from '@vue/test-utils';
import Vuetify from 'vuetify';

import CustomButton from '~/components/ui/CustomButton.vue';

describe('CustomButton', () => {
  const localVue = createLocalVue();
  let vuetify;

  beforeEach(() => {
    vuetify = new Vuetify();
  });

  it('is a Vue instance', () => {
    const wrapper = mount(CustomButton, {
      localVue,
      vuetify,
    });
    expect(wrapper.vm).toBeTruthy();
  });

  it('passes component arguments which props and slot data', async () => {
    // v-btn は別の Component なので Component をスタブする shallowMount を使うと失敗する
    const wrapper = mount(CustomButton, {
      localVue,
      vuetify,
      propsData: { color: 'error' },
      slots: {
        default: 'Hello, World!',
      },
    });

    const button = wrapper.find('button');
    const classes = button.classes();

    // color 情報はそのまま class に書き出されるので default の success は存在しない
    // props で渡した error はそのままクラスになっている
    expect(classes).toContain('error');
    expect(classes).not.toContain('success');

    // button タグには slots.default で渡したラベルが入っている
    expect(button.text()).toBe('Hello, World!');

    // clickCallback なしで 初期状態で click した場合正常に動作する (デフォルト関数のテスト)
    await wrapper.find('button').trigger('click');
  });

  it('checks button disable status', async () => {
    const wrapper = mount(CustomButton, {
      localVue,
      vuetify,
      propsData: {
        clickCallback: () => {
          // callback イベントが呼び出されるときはボタンは無効
          expect(wrapper.vm.disabled).toBe(true);
        },
      },
    });

    // 初期状態では有効
    expect(wrapper.vm.disabled).toBe(false);
    await wrapper.find('button').trigger('click');

    // click イベントが終わった時点でも有効
    expect(wrapper.vm.disabled).toBe(false);
  });

  it('raises exception in clickCallback', async () => {
    const wrapper = mount(CustomButton, {
      localVue,
      vuetify,
      propsData: {
        clickCallback: () => {
          // callback イベントが呼び出されるときはボタンは無効
          expect(wrapper.vm.disabled).toBe(true);
          throw 'error';
        },
      },
    });

    // 初期状態では有効
    expect(wrapper.vm.disabled).toBe(false);
    await wrapper.find('button').trigger('click');

    // clickCallback 内で例外が throw されても正常に終了する
    expect(wrapper.vm.disabled).toBe(false);
  });
});
```

```bash
$ yarn test
yarn run v1.22.10
$ jest
 PASS  test/components/ui/CustomButton.spec.js
  CustomButton
    ✓ is a Vue instance (122 ms)
    ✓ passes component arguments which props and slot data (69 ms)
    ✓ checks button disable status (31 ms)
    ✓ raises exception in clickCallback (268 ms)

  console.log
    error

      at VueComponent.click (components/ui/CustomButton.vue:34:1)

... (略) ...
 components/ui     |     100 |      100 |     100 |     100 |                   
  CustomButton.vue |     100 |      100 |     100 |     100 |                   
... (略) ...
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        2.31 s
Ran all test suites.
Done in 2.91s.
```

このようにコンポーネントのテストを記載することができた。


# まとめ

実際に有用性のある小さいコンポーネントを作成し、その単体テストを記載した。

コンポーネントの単体テストは直接関数やオブジェクトに実施するバックエンドの一般的な単体テストとはやや異なり、イベントの発火やDOMのレンダリング結果を適用できるライブラリ(Wrapper)があってこそ容易な単体テストが実行可能に見える。 そのため、よく語られる単体テストだけでなく、ライブラリの使い方を合わせて覚える必要があると改めて感じた。