ナウい kintone カスタマイズ (5) ユニットテスト編

# 目次

[(1) 環境構築編](https://qiita.com/iShinkai/items/caac0b60e882ff452512)
[(2) アプリ構築・設定編](https://qiita.com/iShinkai/items/f832ffd0436a3630832e)
[(3) アプリ実装編 part 1](https://qiita.com/iShinkai/items/e2c808767b74451ab1b0)
[(4) アプリ実装編 part 2](https://qiita.com/iShinkai/items/5ffedbe041741e541d1e)
(5) ユニットテスト編（この記事）

一連のソースは [GitHub](https://github.com/iShinkai/kintone-vue-ts-lead-management) で公開しています。

# 前置き

このシリーズも 5 回目にしてようやく最終回です。
[第 4 回](https://qiita.com/iShinkai/items/5ffedbe041741e541d1e) の記事では、その前までに作成した [Trello](https://trello.com/) 風案件管理アプリで、カードを動かしたら kintone にレコード更新に行く実際の部分を実装しました。
アプリとしては一通り完成の運びとなりましたが、このままではこれテスト不十分じゃね？と言うか全くしてなくね？と言う批判が渦巻いてしまいますので、そこらへんをなんとかしようと言うのが今回の趣旨です。

なお、テストに関しては [kintone Adovent Calendar](https://qiita.com/advent-calendar/2019/kintone) で非常に素晴らしい記事が公開されていました。

[0 から始める kintone 自動テスト](https://qiita.com/t2kojima/items/e9dc5c91980fed065fae)

kintone 特有の事情をグローバルオブジェクトや mock で立ち回るとても良い解決策が豊富な実例で解説されています。
正直「そちらを見てください」で終わらせてもいいぐらいかなとも思いますが、こちらは `Vue.js` であったり `TypeScript` であったりとまた別の事情があるのでその辺りを中心に解説していきます。

# 前提

以下の環境で作業しています。

- macOS Catalina
- Homebrew 2.1.16
- Node.js 13.1.0
- VisualStudio Code 1.40.1

[(1) 環境構築編](https://qiita.com/iShinkai/items/caac0b60e882ff452512)の記事で、以下をセットアップしました。

- Vue.js 4.0.5
- TypeScript 3.5.3
- vue-cli-plugin-pug 1.0.7

他、プロジェクト作成時の流れで `Sass / SCSS` や `ESLint`、`Prettier`、`Jest` などがセットアップされています。

# 今回のゴール

[前回](https://qiita.com/iShinkai/items/5ffedbe041741e541d1e)完成した案件管理アプリが、仕様通りの実装になっているかを**[Jest](https://jestjs.io/ja/)** によるユニットテストで確認していきます。
もちろんユニットテストよりも上の階層のテストは必要ではありますが、ユニットテストによりコンポーネント単体での実装に誤りがない事を積み上げる事で手戻りを防ぐのが重要です。

以下の順で説明していきます。

- テストの粒度
- コンポーネントのテスト
- kintone 特有の事情に対応する

# (5) ユニットテスト編

## テストの粒度

概念的な話です。
多くの Web アプリケーションは一般的に多数のコンポーネントの集合で、今回作った案件管理アプリもそのご多分に漏れず `Board`、`List`、`Card` などのコンポーネントの組み合わせで構築されています。
それらは有機的に作用し合うように見えますが、**コンポーネント指向** の特徴である **責務の局所化** や **関心の分離** と言った概念により、コンポーネントが受け持つ責務は機械的なテストが可能な程度まで局所化・矮小化されるのがあるべき姿です。
逆に言えばこれができないようなコンポーネントは粒度が粗すぎると言えるでしょう。
疎結合性が担保されたコンポーネントはそのコンポーネント自体の振る舞いと親ないし子コンポーネントとの連接点のみに責任を負えば良く、コンポーネントの内側で行われている事は（少なくともブラックボックステストの文脈では）テストは関与しなくて良いのです。
（とは言っても実際はパフォーマンステストとか保守性の確保とかもありますからコンポーネントは目的を果たせればどんな汚いコードでも構わないと言うわけでは全然ないんですけど。）

そう言う意味で、今回のような構成のシステムにおいて**はユニットテストの最小単位となるのは個々のコンポーネント**であり、**その中のメソッド 1 つ 1 つにまで関与する必要はない**と言えるでしょう。
もちろんコンポーネントが別途作成した関数スクリプトを import しているような場面があるならその関数スクリプトもユニットテストの対象にはなりますけどね。

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

上述通り今回の案件管理アプリは `Board`、`List`、`Card` 各コンポーネントで構成されており、さらに `Board` をマウントする `App` コンポーネントがあります。
<img width="501" alt="resized-00-001-components.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/b8a0800d-5f95-7cc3-11b0-8dcf95f0fa95.png">
基本的にはこれらに対して 1 つずつテストコードを書いて行く事になります。
`Jest` とか `vue-test-utils` などについては公式や参考サイトを当たってその位置付けや役割を把握しておいていただければと思います。
テストファイルは `tests/unit/components/(コンポーネント名).spec.ts` で作っていきます。

### Board コンポーネントのテスト

上から順番にいきましょう。まずは `Board` コンポーネントからです。

```typescript:tests/unit/components/Board.spec.ts
import { shallowMount, mount } from "@vue/test-utils";
import Board from "@/components/Board.vue";

// Font Awesome
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
library.add(fas);

// テストデータ
const records: leadManagement.types.SavedFields[] = require("../../records.json");

describe("Board コンポーネントのマウント", () => {
  // コンポーネントのマウントテスト
  it("正しくマウントできるか", () => {
    const wrapper = shallowMount(Board, {
      propsData: { records }
    });
    expect(wrapper.is(Board)).toBeTruthy();
  });

  // リストが3つ存在するかどうかのテスト
  it("リストが3つ存在するか", () => {
    const wrapper = shallowMount(Board, {
      propsData: { records }
    });
    expect(wrapper.findAll(".list").length).toBe(3);
  });
});

describe("レコードのリスト分類のテスト", () => {
  // テストデータのレコードを確度で分類して数を算出しておく
  let cards: {
    [key: string]: any;
    A: number;
    B: number;
    C: number;
  } = { A: 0, B: 0, C: 0 };
  records.forEach(r => {
    cards[r.確度.value]++;
  });

  // 確度のリストのカードの枚数がテストデータの通りかのテスト
  it("確度のリストのカードの枚数が指定通りかどうか", () => {
    const wrapper = mount(Board, {
      propsData: { records },
      stubs: {
        FontAwesomeIcon
      }
    });
    expect(wrapper.findAll(".list[data-group='A'] .card").length).toBe(
      cards["A"]
    );
    expect(wrapper.findAll(".list[data-group='B'] .card").length).toBe(
      cards["B"]
    );
    expect(wrapper.findAll(".list[data-group='C'] .card").length).toBe(
      cards["C"]
    );
  });
});

describe("イベントのテスト", () => {
  it("ドラッグ終了イベントが emit されるか", () => {
    const wrapper = shallowMount(Board, {
      propsData: { records },
      stubs: {
        FontAwesomeIcon
      }
    });
    wrapper.vm.$emit("card-moved", {
      id: records[0].$id.value,
      group: records[0].確度.value
    });
    expect(wrapper.emitted("card-moved")).toBeTruthy();
    expect(wrapper.emitted("card-moved")[0][0].id).toEqual(
      records[0].$id.value
    );
    expect(wrapper.emitted("card-moved")[0][0].group).toEqual(
      records[0].確度.value
    );
  });
});
```

ざっくりポイントを。

#### テストデータをファイルで用意する

親コンポーネントから貰うレコードは別途 JSON ファイルをテストデータとして用意します。
**ユニットテストは基本的に実行結果が冪等である事が極めて重要**なので、実際の kintone のレコードを見るようなやり方は相応しくないのです。

#### 各リストに表示されるカードの数を数える理由

2 つめのテストでそれぞれのリスト内のカードの数を数えています。
リスト内のカードの数は `List` コンポーネントの責務なのでは？と思われるかも知れませんが、`List` コンポーネントは貰ったレコードセットをカード化するだけが自身の責務であり、そのリストに渡すセットを決める（レコードを確度で分類する）のはこの `Board` コンポーネントの仕事です。
ここで `mount()` メソッドを使用して実際にコンポーネントをマウントしてカードの数を数えるのは、その分類の処理に誤りがないかを確かめるのが目的です。

#### FontAwesome をスタブとして使用する

`FontAwesome` を**スタブ**として渡しています。
2 つめのテストで `List` コンポーネントを `shallowMount()` ではなく `mount()` する都合上、その下位の `Card` コンポーネントで用いられる `FontAwesome` が必要になります。
本実装では `App` コンポーネント Vue オブジェクトに「注入」する形で引き渡していましたが、ユニットテストではそうも行かないのでこうやってスタブとして渡すことで解決を図るわけです。

#### イベントのテストは vm.$emit() で確認する

`List` コンポーネントでカードを動かすとこのコンポーネントの `card-moved` が emit されます。
さらにこのコンポーネントはそのハンドラの中でその上位の `App` コンポーネントの `card-moved` を emit します。
これを確認する事で、イベントが繋がるかどうかを確認しています。

### List コンポーネントのテスト

次は `List` コンポーネントです。
基本的にこのコンポーネントは貰った確度のラベルを表示し、レコードの数ぶんカードを作るだけの仕事なので、テストの分量も大した事ありません。

```typescript:tests/unit/components/List.spec.ts
import { shallowMount, mount } from "@vue/test-utils";
import List from "@/components/List.vue";

// Font Awesome
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
library.add(fas);

// テストデータ
const records: leadManagement.types.SavedFields[] = require("../../records.json");

describe("List コンポーネントのマウント", () => {
  // 確度 A だけを抽出しておく
  const aRecords = records.filter(r => r.確度.value === "A");

  // コンポーネントのマウントテスト
  it("正しくマウントできるか", () => {
    const wrapper = shallowMount(List, {
      propsData: { group: "A", records: aRecords }
    });

    // List コンポーネントがある
    expect(wrapper.is(List)).toBeTruthy();

    // ラベルが正しい
    expect(wrapper.find(".list-title-value").text()).toEqual("[A]");
  });
});

describe("カードの枚数", () => {
  // 確度 A だけを抽出しておく
  const aRecords = records.filter(r => r.確度.value === "A");

  // カードの枚数がレコードの数ぶん存在するかどうかのテスト
  it("カードの枚数がレコードの数ぶん存在するか", () => {
    const wrapper = mount(List, {
      propsData: { group: "A", records: aRecords },
      stubs: {
        FontAwesomeIcon
      }
    });
    expect(wrapper.findAll(".card").length).toBe(aRecords.length);
  });
});

describe("イベントのテスト", () => {
  it("ドラッグ終了イベントが emit されるか", () => {
    const wrapper = shallowMount(List, {
      propsData: { group: "A", records: aRecords },
      stubs: {
        FontAwesomeIcon
      }
    });
    wrapper.vm.$emit("card-moved", { id: aRecords[0].$id.value, group: "A" });
    expect(wrapper.emitted("card-moved")).toBeTruthy();
    expect(wrapper.emitted("card-moved")[0][0].id).toEqual(
      aRecords[0].$id.value
    );
    expect(wrapper.emitted("card-moved")[0][0].group).toEqual("A");
  });
});
```

この `List` コンポーネントは `Vue.Draggable` も利用しており、カードのドラッグ＆ドロップでイベント処理を扱っています。
しかしこれ自体は GUI に関わるものですし、 `Vue.Draggable` は外部コンポーネントと言う事もありますので、ユニットテストの範疇でテストする事ではありません。
（E2E テストなど違う視点でテストするべき項目です）
その代わり、`emit` が正しく実行されているかどうか（＝親コンポーネントのメソッドを呼びに行っているか）をテストしています。

### Card コンポーネントのテスト

次は `Card` コンポーネントです。
このコンポーネントは表示の中心となるため、1 つ 1 つの表示項目単位でテストをしていきます。
また、値が空欄などの場合の表示もテストします。

```typescript:tests/unit/components/Card.vue
import { shallowMount, mount } from "@vue/test-utils";
import Card from "@/components/Card.vue";

// Font Awesome
import { library } from "@fortawesome/fontawesome-svg-core";
import { fas } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
library.add(fas);

// テストデータ
const records: leadManagement.types.SavedFields[] = require("../../records.json");

describe("Card コンポーネントのマウントと値", () => {
  const record = JSON.parse(JSON.stringify(records[0]));
  const wrapper = shallowMount(Card, {
    propsData: { record },
    stubs: {
      FontAwesomeIcon
    }
  });

  // コンポーネントのマウントテスト
  it("正しくマウントできるか", () => {
    expect(wrapper.is(Card)).toBeTruthy();
  });

  // 値のテスト

  it("ラベルが正しいかどうか", () => {
    expect(wrapper.find(".card-title").text()).toEqual(record.会社名.value);
  });

  it("案件担当者が正しいかどうか", () => {
    expect(wrapper.find(".line-charge .value").text()).toEqual(
      record.案件担当者名.value.map((v: { name: string }) => v.name).join(", ")
    );
  });

  it("見込み時期が正しいかどうか", () => {
    expect(wrapper.find(".line-period .value").text()).toEqual(
      record.見込み時期.value.replace(/-/g, "/")
    );
  });

  it("製品名が正しいかどうか", () => {
    expect(wrapper.find(".line-product-name .value").text()).toEqual(
      record.製品名.value
    );
  });

  it("単価が正しいかどうか", () => {
    expect(wrapper.find(".line-unit .value").text()).toEqual(
      Number(record.単価.value).toLocaleString()
    );
  });

  it("ユーザー数が正しいかどうか", () => {
    expect(wrapper.find(".line-user-count .value").text()).toEqual(
      `${Number(record.ユーザー数.value).toLocaleString()} 人`
    );
  });

  it("小計が正しいかどうか", () => {
    expect(wrapper.find(".line-subtotal .value").text()).toEqual(
      `￥ ${Number(record.小計.value).toLocaleString()}`
    );
  });
});

describe("異常値処理", () => {
  // 異常値を差し込んでおく
  const record = JSON.parse(JSON.stringify(records[0]));
  record.案件担当者名.value = [];
  record.見込み時期.value = "";
  record.単価.value = "";
  record.ユーザー数.value = "";
  record.小計.value = "";

  const wrapper = shallowMount(Card, {
    propsData: { record },
    stubs: {
      FontAwesomeIcon
    }
  });

  // 値のテスト

  it("案件担当者が正しいかどうか", () => {
    expect(wrapper.find(".line-charge .value").text()).toEqual("--");
  });

  it("見込み時期が正しいかどうか", () => {
    expect(wrapper.find(".line-period .value").text()).toEqual("--");
  });

  it("単価が正しいかどうか", () => {
    expect(wrapper.find(".line-unit .value").text()).toEqual("--");
  });

  it("ユーザー数が正しいかどうか", () => {
    expect(wrapper.find(".line-user-count .value").text()).toEqual("--");
  });

  it("小計が正しいかどうか", () => {
    expect(wrapper.find(".line-subtotal .value").text()).toEqual("--");
  });
});
```

### App コンポーネント

最後に、これらの大元となる `App` コンポーネントのテストです。

```typescript:tests/unit/App.spec.ts
import { shallowMount, mount } from "@vue/test-utils";
import App from "@/App.vue";

describe("App コンポーネントのマウント", () => {
  // コンポーネントのマウントテスト
  it("正しくマウントできるか", () => {
    const wrapper = shallowMount(App);
    expect(wrapper.is(App)).toBeTruthy();
  });
});
```

と言っても正しくマウントできるかどうかのチェックのみです。

### テストを実行する

こんな感じでテストコードを作って、

```bash
% yarn test:unit
```

と実行すれば、
<img alt="resized-01-001-all.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/c95aebf8-7f45-78b3-f3c4-45874e664aba.png">

と言った具合に、テスト結果が表示されます。

また、以下のようにすれば個々のファイル毎にテストが実行できます。

```bash
% yarn test:unit (個々のテストファイルパス)
```
<img alt="resized-01-002-each.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/2b4451c5-022e-7aa8-7dcc-20beb921f464.png">

# まとめ

と言うわけで、`Jest` によるユニットテストについてざっと見て来ました。
kintone と言うプラットフォーム上で構成するシステムですが、コンポーネントレベルでは kintone 特有のアレコレに左右される事なくテストが行えます。
と言うかそう言うふうに作らなければコンポーネント指向の意味合いは半減してしまうわけです。

とは言え、カードを動かしたらリスト内のカードの枚数が変化するとか、その結果として kintone にレコード更新に行くとか、そう言う部分もまた本アプリの構成要素としては重要であるのは間違いなく、また別の視点でテストが必要な部分になります。
そこらへんは E2E テストの範疇になって来るのでまた違ったアプローチが必要になって来るところです。
[最初の方で挙げた記事](https://qiita.com/t2kojima/items/e9dc5c91980fed065fae)などを参考にすればより高度かつ網羅的ななテストが行えるでしょう。

と言うわけで、この一連のシリーズはこれにて終了と言う事にしたいと思います。
ありがとうございました。