Vue3でコンポーネントからロジックを分離し、ユニットテストを書く

## 記事を書いたきっかけ

普段私は、バックエンドエンジニアとして働いているが、軽めのフロントエンド改修PJ(Vue)に携わることも多くなり、以下のような悩みが出てきた。

- ロジックをコンポーネントに書くと、すぐにコンポーネントが千行を突破し、コードを追うのが大変、、、
- ロジックも複雑になり、バグの発見が遅れることも、、、

なんとか**コンポーネンの見通しを良くし、ロジックにユニットテストが書ける**構造を模索し、ここにメモ。

## 時間がない人のための要約

- Vue3では Composition API & [`<script setup>`](https://ja.vuejs.org/api/sfc-script-setup.html)を用いて簡潔な記述が可能。
- 記述した**状態を持つロジック**を[composable](https://ja.vuejs.org/guide/reusability/composables.html)(Reactの[Custom Hooks](https://ja.react.dev/learn/reusing-logic-with-custom-hooks#custom-hooks-sharing-logic-between-components)のようなもの)に切り出す。
- composable(≒ ロジック)に対してユニットテストが書ける!!

-> **コードを整理し、成果物のバグ減少を実現!!(できるはず!!)**

## 実装

最初に「コンポーネントにロジックをベタ書き」で実装し、次に「ロジックをcomposableに切り出し」で実装を試みる。

今回は、以下のような「旅行の持ち物リスト一括編集画面」を実装する。

![ezgif-4-9fd62ebf14.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290859/e74407bb-f3e9-a142-905a-efb49f932f51.gif)

機能概要は以下の通り。

1. 画面表示時に既存データをフェッチ
1. 項目の追加
1. 項目の削除
1. 編集後のリストをsubmit(今回はalertのダミー)

次からは具体的な実装に入る。

### コンポーネントにロジックをベタ書きで実装

まずはコンポーネントにロジックを直接書く。
※機能数が多いわけでもないが、すでにコンポーネントが肥大化している。

```vue:App.vue
<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>
```

次に、説明のため簡略化したコードを以下に示す。

```vue:App.vue(抜粋)
<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として切り出す**ことができる。

### コンポーネントからロジックを分離して実装

実際に分離した処理は以下の通り。

```typescript:useEditBelongings.ts (抜粋)
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
  };
};
```

分離した処理はコンポーネント側で呼び出す。

```vue:App.vue(抜粋)
<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(関数)は簡単にテストを書くことができる。

### 導入方法

まずは[公式に記載の手順](https://ja.vuejs.org/guide/scaling-up/testing.html#adding-vitest-to-a-project)でVitestを導入する。

::: note warn
公式の手順をそのまま適用すると`vite.config.ts`でエラーとなるため、[stack overflow](https://stackoverflow.com/questions/72146352/vitest-defineconfig-test-does-not-exist-in-type-userconfigexport)の回答を参照してください。
:::

次に仕様を担保するテストケースを用意する。

```typescript:useEditBelongings.test.ts (抜粋)
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("");
  });
...
```

ついでにテスト実行。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290859/c6b426ab-d515-2ae4-d892-14086fd1e449.png)

Yay!! :sparkles: 

## 発展的なテスト

### Lifecycle Hooksを含むcomposableのテスト

`onBeforeMount`などの、Lifecycle Hooksを含むcomposableをテストする場合は、[Vue Test Utils](https://test-utils.vuejs.org/)を用いて、[テスト用のコンポーネントをマウントする必要がある。](https://test-utils.vuejs.org/guide/advanced/reusability-composition.html#testing-composables)

## まとめ

今回はVue3のComposition APIを使用して、ロジックをcomposableに切り出し、ユニットテストを書いた。

また今回使用したソースの全体はGithubにおいてある。

https://github.com/yasuaki640/composable-sample

以上。