目次
(1) 環境構築編
(2) アプリ構築・設定編
(3) アプリ実装編 part 1
(4) アプリ実装編 part 2
(5) ユニットテスト編(この記事)
一連のソースは GitHub で公開しています。
前置き
このシリーズも 5 回目にしてようやく最終回です。
第 4 回 の記事では、その前までに作成した Trello 風案件管理アプリで、カードを動かしたら kintone にレコード更新に行く実際の部分を実装しました。
アプリとしては一通り完成の運びとなりましたが、このままではこれテスト不十分じゃね?と言うか全くしてなくね?と言う批判が渦巻いてしまいますので、そこらへんをなんとかしようと言うのが今回の趣旨です。
なお、テストに関しては kintone Adovent Calendar で非常に素晴らしい記事が公開されていました。
kintone 特有の事情をグローバルオブジェクトや mock で立ち回るとても良い解決策が豊富な実例で解説されています。
正直「そちらを見てください」で終わらせてもいいぐらいかなとも思いますが、こちらは Vue.js
であったり TypeScript
であったりとまた別の事情があるのでその辺りを中心に解説していきます。
前提
以下の環境で作業しています。
- macOS Catalina
- Homebrew 2.1.16
- Node.js 13.1.0
- VisualStudio Code 1.40.1
(1) 環境構築編の記事で、以下をセットアップしました。
- Vue.js 4.0.5
- TypeScript 3.5.3
- vue-cli-plugin-pug 1.0.7
他、プロジェクト作成時の流れで Sass / SCSS
や ESLint
、Prettier
、Jest
などがセットアップされています。
今回のゴール
前回完成した案件管理アプリが、仕様通りの実装になっているかを**Jest** によるユニットテストで確認していきます。
もちろんユニットテストよりも上の階層のテストは必要ではありますが、ユニットテストによりコンポーネント単体での実装に誤りがない事を積み上げる事で手戻りを防ぐのが重要です。
以下の順で説明していきます。
- テストの粒度
- コンポーネントのテスト
- kintone 特有の事情に対応する
(5) ユニットテスト編
テストの粒度
概念的な話です。
多くの Web アプリケーションは一般的に多数のコンポーネントの集合で、今回作った案件管理アプリもそのご多分に漏れず Board
、List
、Card
などのコンポーネントの組み合わせで構築されています。
それらは有機的に作用し合うように見えますが、コンポーネント指向 の特徴である 責務の局所化 や 関心の分離 と言った概念により、コンポーネントが受け持つ責務は機械的なテストが可能な程度まで局所化・矮小化されるのがあるべき姿です。
逆に言えばこれができないようなコンポーネントは粒度が粗すぎると言えるでしょう。
疎結合性が担保されたコンポーネントはそのコンポーネント自体の振る舞いと親ないし子コンポーネントとの連接点のみに責任を負えば良く、コンポーネントの内側で行われている事は(少なくともブラックボックステストの文脈では)テストは関与しなくて良いのです。
(とは言っても実際はパフォーマンステストとか保守性の確保とかもありますからコンポーネントは目的を果たせればどんな汚いコードでも構わないと言うわけでは全然ないんですけど。)
そう言う意味で、今回のような構成のシステムにおいてはユニットテストの最小単位となるのは個々のコンポーネントであり、その中のメソッド 1 つ 1 つにまで関与する必要はないと言えるでしょう。
もちろんコンポーネントが別途作成した関数スクリプトを import しているような場面があるならその関数スクリプトもユニットテストの対象にはなりますけどね。
コンポーネントのテスト
上述通り今回の案件管理アプリは Board
、List
、Card
各コンポーネントで構成されており、さらに Board
をマウントする App
コンポーネントがあります。
基本的にはこれらに対して 1 つずつテストコードを書いて行く事になります。
Jest
とか vue-test-utils
などについては公式や参考サイトを当たってその位置付けや役割を把握しておいていただければと思います。
テストファイルは tests/unit/components/(コンポーネント名).spec.ts
で作っていきます。
Board コンポーネントのテスト
上から順番にいきましょう。まずは Board
コンポーネントからです。
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
コンポーネントです。
基本的にこのコンポーネントは貰った確度のラベルを表示し、レコードの数ぶんカードを作るだけの仕事なので、テストの分量も大した事ありません。
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 つの表示項目単位でテストをしていきます。
また、値が空欄などの場合の表示もテストします。
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
コンポーネントのテストです。
import { shallowMount, mount } from "@vue/test-utils";
import App from "@/App.vue";
describe("App コンポーネントのマウント", () => {
// コンポーネントのマウントテスト
it("正しくマウントできるか", () => {
const wrapper = shallowMount(App);
expect(wrapper.is(App)).toBeTruthy();
});
});
と言っても正しくマウントできるかどうかのチェックのみです。
テストを実行する
こんな感じでテストコードを作って、
% yarn test:unit
と言った具合に、テスト結果が表示されます。
また、以下のようにすれば個々のファイル毎にテストが実行できます。
% yarn test:unit (個々のテストファイルパス)
まとめ
と言うわけで、Jest
によるユニットテストについてざっと見て来ました。
kintone と言うプラットフォーム上で構成するシステムですが、コンポーネントレベルでは kintone 特有のアレコレに左右される事なくテストが行えます。
と言うかそう言うふうに作らなければコンポーネント指向の意味合いは半減してしまうわけです。
とは言え、カードを動かしたらリスト内のカードの枚数が変化するとか、その結果として kintone にレコード更新に行くとか、そう言う部分もまた本アプリの構成要素としては重要であるのは間違いなく、また別の視点でテストが必要な部分になります。
そこらへんは E2E テストの範疇になって来るのでまた違ったアプローチが必要になって来るところです。
最初の方で挙げた記事などを参考にすればより高度かつ網羅的ななテストが行えるでしょう。
と言うわけで、この一連のシリーズはこれにて終了と言う事にしたいと思います。
ありがとうございました。