記事を書いたきっかけ
普段私は、バックエンドエンジニアとして働いているが、軽めのフロントエンド改修PJ(Vue)に携わることも多くなり、以下のような悩みが出てきた。
- ロジックをコンポーネントに書くと、すぐにコンポーネントが千行を突破し、コードを追うのが大変、、、
- ロジックも複雑になり、バグの発見が遅れることも、、、
なんとかコンポーネンの見通しを良くし、ロジックにユニットテストが書ける構造を模索し、ここにメモ。
時間がない人のための要約
- Vue3では Composition API &
<script setup>
を用いて簡潔な記述が可能。 - 記述した状態を持つロジックをcomposable(ReactのCustom Hooksのようなもの)に切り出す。
- composable(≒ ロジック)に対してユニットテストが書ける!!
-> コードを整理し、成果物のバグ減少を実現!!(できるはず!!)
実装
最初に「コンポーネントにロジックをベタ書き」で実装し、次に「ロジックをcomposableに切り出し」で実装を試みる。
今回は、以下のような「旅行の持ち物リスト一括編集画面」を実装する。
機能概要は以下の通り。
- 画面表示時に既存データをフェッチ
- 項目の追加
- 項目の削除
- 編集後のリストをsubmit(今回はalertのダミー)
次からは具体的な実装に入る。
コンポーネントにロジックをベタ書きで実装
まずはコンポーネントにロジックを直接書く。
※機能数が多いわけでもないが、すでにコンポーネントが肥大化している。
<script setup lang="ts">
import { ref } from "vue";
import { v4 as uuidv4 } from "uuid";
type Belonging = {
id: string;
name: string;
};
const loading = ref(false);
const belongings = ref<Belonging[]>([]);
loading.value = true;
fetch(import.meta.env.VITE_API_DOMAIN + "/belongings")
.then((res) => res.json())
.then((json) => {
belongings.value = json;
})
.catch((e) => {
alert(e);
})
.finally(() => {
loading.value = false;
});
const removeBelonging = (id: string) => {
belongings.value = belongings.value.filter((t) => t.id !== id);
};
const nameInput = ref("");
const addBelonging = () => {
if (!nameInput.value) {
return;
}
belongings.value.push({
id: uuidv4(),
name: nameInput.value
});
nameInput.value = "";
};
const submitEdit = () => {
loading.value = true;
const json = JSON.stringify(belongings.value, null, 2);
alert("Submitted belonging list is below : \n" + json);
belongings.value = [];
loading.value = false;
location.reload();
};
</script>
<template>
<main>
<h1>Vue3 Composable Sample</h1>
<h2>旅行の持ち物 一括編集</h2>
<section v-if="loading">Loading...</section>
<section v-else>
<ul>
<li v-for="belonging in belongings" :key="belonging.id">
{{ belonging.name }}
<button @click="removeBelonging(belonging.id)">remove</button>
</li>
</ul>
<div id="add-todo-pane">
<input v-model="nameInput" type="text" />
<button @click="addBelonging">add</button>
</div>
<div id="submit-button">
<button @click="submitEdit">submit</button>
</div>
</section>
</main>
</template>
<style scoped>
#submit-button {
padding: 10px;
}
</style>
次に、説明のため簡略化したコードを以下に示す。
<script setup lang="ts">
// -------- [状態] --------
// 持ち物リスト
const belongings = ref<Belonging[]>([]);
// inputに入力された値
const nameInput = ref("");
// ロード中フラグ
const loading = ref(false);
// ----------------------
// -------- [ロジック] --------
// 初期表示時に既存の持ち物リストを取得する処理
loading.value = true;
fetch(import.meta.env.VITE_API_DOMAIN + "/belongings")
...
// 持ち物を削除する関数
const removeBelonging = (id: string) => {...};
// 入力された値をリストに追加する関数
const addBelonging = () => {...};
// 編集された持ち物リストをsubmit (今回はダミー関数)
const submitEdit = () => {...};
// ----------------------
</script>
<template>
...
</template>
ここで記述されているコードは「状態(持ち物リスト,etc)」とそれに関連する「ロジック」のため、
composableとして切り出すことができる。
コンポーネントからロジックを分離して実装
実際に分離した処理は以下の通り。
export const useEditBelongings = () => {
// -------- [状態] --------
// 持ち物リスト
const belongings = ref<Belonging[]>([]);
// inputに入力された値
const nameInput = ref("");
// ロード中フラグ
const loading = ref(false);
// ----------------------
// -------- [ロジック] --------
// 初期表示時に既存の持ち物リストを取得する処理
loading.value = true;
fetch(import.meta.env.VITE_API_DOMAIN + "/belongings")
...
// 持ち物を削除する関数
const removeBelonging = (id: string) => {...};
// 入力された値をリストに追加する関数
const addBelonging = () => {...};
// 編集された持ち物リストをsubmit (今回はダミー関数)
const submitEdit = () => {...};
// ----------------------
return {
belongings,
loading,
nameInput,
removeBelonging,
appendBelonging,
submitEdit
};
};
分離した処理はコンポーネント側で呼び出す。
<script setup lang="ts">
import { useEditBelongings } from "@/hooks/useEditBelongings";
const {
belongings,
loading,
nameInput,
removeBelonging,
appendBelonging,
submitEdit
} = useEditBelongings();
</script>
<template>
<main>
<section v-if="loading">Loading...</section>
...
ユニットテストを書く
先のように切り出したcomposable(関数)は簡単にテストを書くことができる。
導入方法
まずは公式に記載の手順でVitestを導入する。
公式の手順をそのまま適用するとvite.config.ts
でエラーとなるため、stack overflowの回答を参照してください。
次に仕様を担保するテストケースを用意する。
import { describe, expect, it, vi } from "vitest";
import { useEditBelongings } from "@/hooks/useEditBelongings";
// 処理を一時停止するテスト用関数
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
describe("useEditBelongings", () => {
// モック実装をテストケースの前にリセット
beforeEach(async () => {
vi.resetAllMocks();
});
// テストケース
it("inputに入力した値を追加できる", async () => {
// 持ち物リスト呼び出しapiをモック
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(
'[{"id":"408d9383-b6b6-4571-81f8-d0323b51c0f5","name":"Mac"},{"id":"10317456-5d94-41dd-adb6-8ff85d5a36fe","name":"Thinkpad (Arch Linux installed)"}]'
)
);
// composableをcall
const { belongings, loading, nameInput, appendBelonging } = useEditBelongings();
// composableがcallされたときに実行される、初期値呼び出しAPIを待つ
while (loading.value) {
await sleep(1);
}
// inputに持ち物を入力
nameInput.value = "iPhone";
// 持ち物を新規追加
appendBelonging();
// 要素が追加されていることを検証
expect(belongings.value).toContainEqual({
id: expect.any(String),
name: "iPhone"
});
// 追加後、inputが空になることを検証
expect(nameInput.value).toBe("");
});
...
ついでにテスト実行。
Yay!!
発展的なテスト
Lifecycle Hooksを含むcomposableのテスト
onBeforeMount
などの、Lifecycle Hooksを含むcomposableをテストする場合は、Vue Test Utilsを用いて、テスト用のコンポーネントをマウントする必要がある。
まとめ
今回はVue3のComposition APIを使用して、ロジックをcomposableに切り出し、ユニットテストを書いた。
また今回使用したソースの全体はGithubにおいてある。
以上。