フロントエンドのテストを書く時に私が考えていること

2021.04.12

最近フロントエンドのテストについて考えることが多いので、自分なりの観点をまとめてみます。

どの程度の粒度で書くのが適切かはプロジェクトやチームによって色々有ると思うので、

あくまで私個人としての考え方になりますのでご理解ください。

最終的にできあがったソースはこちら

環境

今回は以下のバージョンで検証しています。

  • Node: v15.8.0
  • Vue 3
  • Vue Test Utils 2

特にVue 3とVue Test Utils 2に関しては前バージョンからの変更箇所が多いので、実際に動かす場合は注意してください。

想定する画面

以下のようなテーブルからなる画面を想定します。

簡易なユーザー管理画面のようなイメージで、

  • 削除フラグが立っているユーザーには取り消し線が付き
  • 右のボタンを押すと削除フラグが立ち
  • 管理者ユーザーの場合はボタンが非活性になる(削除できない)

みたいな仕様とします。

コンポーネントは3つに分かれています。

App.vue

<template>
  <table>
    <tr>
      <th>ID</th>
      <th>Name</th>
      <th>Mail</th>
      <th>Delete</th>
    </tr>
    <UserRow
      v-for="user in users"
      :key="user.id"
      :user="user"
      :onDeleteBtnClick="deleteUser"
    />
  </table>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { User } from "@/@types";
import { getUsers } from "@/usecase/show-users";
import UserRow from "@/components/UserRow.vue";

export default defineComponent({
  name: "App",
  components: {
    UserRow,
  },
  data(): { users: User[] } {
    return {
      users: [],
    };
  },
  mounted: function () {
    // 仮のデータ取得
    this.users = getUsers();
  },
  methods: {
    // 仮の削除処理
    deleteUser(id: number) {
      for (let i = 0; i < this.users.length; i++) {
        if (this.users[i].id === id) {
          this.users[i].isDeleted = true;
        }
      }
    },
  },
});
</script>

UserRow.vue

<template>
  <tr :class="user.isDeleted ? 'disabled' : ''">
    <td>{{ user.id }}</td>
    <td>{{ user.name }}</td>
    <td>{{ user.mail }}</td>
    <td>
      <DeleteButton
        :isAdmin="user.isAdmin"
        :onClick="onDeleteBtnClick"
        :targetId="user.id"
      />
    </td>
  </tr>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import DeleteButton from "./DeleteButton.vue";

export default defineComponent({
  name: "UserRow",
  components: {
    DeleteButton,
  },
  props: {
    user: {
      type: Object,
      required: true,
    },
    onDeleteBtnClick: {
      type: Function,
      required: true,
    },
  },
});
</script>

<style scoped>
.disabled {
  text-decoration: line-through;
}
</style>

DeleteButton.vue

<template>
  <button :disabled="isAdmin" @click="onClick(targetId)">Delete</button>
</template>

<script lang="ts">
import { defineComponent } from "vue";

export default defineComponent({
  name: "DeleteButton",
  props: {
    isAdmin: {
      type: Boolean,
      required: true,
    },
    onClick: {
      type: Function,
      required: true,
    },
    targetId: {
      type: Number,
      required: true,
    },
  },
});
</script>

これらをテストしていきます。

観点

私が考えている大きな観点としては以下になります。

  • 表示(template部分)に関するテスト
  • 各コンポーネント内のイベントやロジックに関するテスト
  • コンポーネント間に跨る挙動に関するテスト

このうち上2つは各コンポーネント毎、最後は各ページ毎に考えます。

まずは最も小さいコンポーネントであるDeleteButtonから考えていきます。

DeleteButtonのテスト

まずは表示に関する部分を考えます。

template部分を見てみると、

  • Propsで受け取ったデータであるisAdminによってdisabled属性を付与するかどうかを決めている

ことがわかると思います。

(あとはボタンのテキストがDeleteとなっています。多言語対応等を考える場合は表示文言についてもテストをしたりすることもありますが、今回は固定文言については特に考えないこととします。)

次にイベントやロジックに関する部分を見ていきます。

  • クリック時にPropsで受け取ったonClickを実行するようになっている

ことがわかると思います。

これらを踏まえてテストを書いてみます。

DeleteButton.spec.ts

import { mount } from "@vue/test-utils";
import DeleteButton from "@/components/DeleteButton.vue";

// 管理者テストデータ
const adminData = {
  isAdmin: true,
  onClick: jest.fn(),
  targetId: 1,
};
// 一般ユーザーテストデータ
const userData = {
  isAdmin: false,
  onClick: jest.fn(),
  targetId: 1,
};

describe("DeleteButton: 管理者でのテスト", () => {
  const wrapper = mount(DeleteButton, {
    props: { ...adminData },
  });
  it("propsでisAdmin: trueを受け取った場合、disabled属性が付与される", () => {
    expect(wrapper.attributes()).toHaveProperty("disabled");
  });
  it("disabled属性がある場合はクリック時にonClickが発火されないこと", () => {
    wrapper.trigger("click");
    expect(adminData.onClick).not.toHaveBeenCalled();
  });
});

describe("DeleteButton: 一般ユーザーでのテスト", () => {
  const wrapper = mount(DeleteButton, {
    props: { ...userData },
  });
  it("propsでisAdmin: falseを受け取った場合、disabled属性が付与されないこと", () => {
    expect(wrapper.attributes()).not.toHaveProperty("disabled");
  });
  it("disabled属性がない場合はクリック時にonClickが発火されること", () => {
    wrapper.trigger("click");
    expect(userData.onClick).toHaveBeenCalled();
    // 引数のチェック
    expect(userData.onClick.mock.calls[0][0]).toBe(userData.targetId);
  });
});

disabled属性の有無に関するテストとクリック時のテストを管理者ユーザーの場合と一般ユーザーの場合でそれぞれ記述しています。

ポイントとしてはこのテストはDeleteButton単体のテストなので、クリックイベントについてはモックを使用し、親コンポーネントを意識していないことです。

クリック時に渡された関数が想定通りにコールされていることだけチェックしています。

UserRowのテスト

こちらもまずは表示に関する部分を見ていきます。

template部分を見ていくと

  • user.isDeletedによって付与するクラスを分岐している
  • user.id, user.name, user.mailがtdタグに表示されている

のがわかると思います。

また、イベントやロジックについてはPropsを受け取ってDeleteButtonに渡しているだけなので、単体としては特に考えないことにします。

以下のようになりました。

UserRow.spec.ts

import { shallowMount } from "@vue/test-utils";
import UserRow from "@/components/UserRow.vue";
import { User } from "@/@types";

const testUser: User = {
  id: 1,
  name: "Test Name",
  mail: "test@example.com",
  isDeleted: false,
  isAdmin: false,
};

const deletedUser: User = {
  id: 2,
  name: "Test Disable Name",
  mail: "test_disable@example.com",
  isDeleted: true,
  isAdmin: false,
};

describe("UserRowに有効なユーザーが渡されたとき", () => {
  const wrapper = shallowMount(UserRow, {
    props: {
      user: testUser,
      onDeleteBtnClick: jest.fn(),
    },
  });
  it("渡されたデータが正常に描画されること", () => {
    const tdList = wrapper.findAll("td");

    expect(tdList[0].text()).toBe(testUser.id.toString());
    expect(tdList[1].text()).toBe(testUser.name);
    expect(tdList[2].text()).toBe(testUser.mail);
  });
  it("disabledスタイルが適用されていないこと", () => {
    expect(wrapper.classes()).not.toContain("disabled");
  });
});

describe("UserRowに無効なユーザーが渡されたとき", () => {
  const wrapper = shallowMount(UserRow, {
    props: {
      user: deletedUser,
      onDeleteBtnClick: jest.fn(),
    },
  });
  it("disabledスタイルが適用されていること", () => {
    expect(wrapper.classes()).toContain("disabled");
  });
});

tdタグの表示に関するテストとdisabledクラスの有無を確認しています。

基本的にテンプレート部分へのテストはDOM操作に近く、jQueryや素のJavaScript等に触れていた方には馴染みがあるかもしれません。

また、こちらではshallowMountを使用していますが、今回はDeleteButtonのような他のコンポーネントに依存しないテストしか書いてないのでshallowMountを使用しました。一応普通のmountでも問題なく動作します。

Appのテスト

次に一番大きい単位のコンポーネントであるAppのテストを書いていきます。

こちらもtemplateから見ていくと

  • usersの数だけUserRowが表示される

ことがわかるかと思います。

(thタグもありますが、こちらは固定なので今回はスルーします)

次にロジック部分を見ていくと

  • mount時に外部のgetUsers関数を呼んでいる
  • 受け取ったIDのユーザーの削除フラグを立てるdeleteUser関数がある

ことがわかると思います。

これらを踏まえてテストを書いていきます。

以下のように書けます。

App.spec.ts

import { mount } from "@vue/test-utils";
import App from "@/App.vue";
import UserRow from "@/components/UserRow.vue";
import { User } from "@/@types";
import * as showUsers from "@/usecase/show-users";

const testData: User[] = [
  {
    id: 1,
    name: "test",
    mail: "test@example.com",
    isAdmin: false,
    isDeleted: false,
  },
  {
    id: 2,
    name: "test2",
    mail: "test2@example.com",
    isAdmin: false,
    isDeleted: true,
  },
];

describe("App.vueのテスト", () => {
  const wrapper = mount(App, {
    shallow: true,
    data() {
      return {
        users: testData,
      };
    },
  });

  it("deleteUserを実行すると削除フラグが立つこと", () => {
    expect(wrapper.vm.users[0].isDeleted).toBe(false);
    wrapper.vm.deleteUser(1);
    expect(wrapper.vm.users[0].isDeleted).toBe(true);
  });

  it("usersの数だけUserRowがあること", () => {
    expect(wrapper.findAllComponents(UserRow).length).toBe(2);
  });
});

describe("App.vue、マウント時の挙動の確認", () => {
  it("マウント時にgetUsersをコールすること", async () => {
    const spy = jest.spyOn(showUsers, "getUsers");
    const wrapper = mount(App);
    await wrapper.vm.$nextTick();
    expect(spy).toHaveBeenCalled();
  });
});

こちらについても同じように他コンポーネントに跨るようなテストは記述していません。

usersの数だけUserRowがあることを確認するテストについては、UserRowを複数表示するという部分はApp.vue側でやっていることなので、

詳細な表示等に関する部分はUserRow側のテストに任せて行数のみ確認しています。なので基本的にshallowMountで事足りると思います。

(Vue Test Utils for Vue 3だとmountにshallowというフラグが渡せるようになったみたいです)

また、 mountedの挙動については非同期で実行されるため、$nextTick()を使わないと実行されません。

getUsersについては外部の関数を実行しているので、spyを使ってその関数がコールされることのみ確認しています。

(今回は仮実装だったりするのでやりませんが、外部の関数の挙動についてテストしたい場合は別途テストを書きましょう)

コンポーネント間の挙動に関するテスト

最後にコンポーネント間でのテストを書いていきます。

今回はdeleteUserがApp.vue => UserRow => DeleteButtonに渡っていくので、

deleteUserがDeleteButtonのclickによって正しい挙動をするのかを確認します。

App.vueに以下を追記してみます。

App.spec.ts

describe("コンポーネント間のテスト", () => {
  const wrapper = mount(App, {
    data() {
      return {
        users: testData,
      };
    },
  });
  it("DeleteButtonクリック時のテスト", () => {
    const buttons = wrapper.findAllComponents(DeleteButton);
    buttons[0].trigger("click");
    expect(wrapper.vm.users[0].isDeleted).toBe(true);
  });
});

以上です!

最後に

観点を明確にしながら進めていくことで漏れを減らしたり高いカバレッジを実現できるのではないかと思っています。

ただし冒頭でも触れていますが、どこまでテストするが適切かは様々な考え方がありますし、

いい感じに(ちょうどいいコスト、粒度など)テストできることが最重要なので、

あくまで一つの考え方として捉えていただければと思っています。

では、良い自動テストライフをお送りください🙌

参考

Vue Test Utils for Vue 3

Jest · 🃏 Delightful JavaScript Testing