LoginSignup
9
4

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

Last updated at Posted at 2023-05-28

記事を書いたきっかけ

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

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

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

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

  • Vue3では Composition API & <script setup>を用いて簡潔な記述が可能。
  • 記述した状態を持つロジックcomposable(ReactのCustom Hooksのようなもの)に切り出す。
  • composable(≒ ロジック)に対してユニットテストが書ける!!

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

実装

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

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

ezgif-4-9fd62ebf14.gif

機能概要は以下の通り。

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

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

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

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

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>

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

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として切り出すことができる。

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

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

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
  };
};

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

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

導入方法

まずは公式に記載の手順でVitestを導入する。

公式の手順をそのまま適用するとvite.config.tsでエラーとなるため、stack overflowの回答を参照してください。

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

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

Yay!! :sparkles:

発展的なテスト

Lifecycle Hooksを含むcomposableのテスト

onBeforeMountなどの、Lifecycle Hooksを含むcomposableをテストする場合は、Vue Test Utilsを用いて、テスト用のコンポーネントをマウントする必要がある。

まとめ

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

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

以上。

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4