💬

最低限のユニットテストを行うためのVue.jsの設計ルール

2023/11/03に公開

前提

フロントエンドには多くのユニットテストがある

案件の特性や工数により、多くの種類の自動テストを行うのが難しい場合がある

ユニットテストの取捨選択を誤りうまくいかなかった反省のため、最低限のユニットテストについてまとめる

サンプルコードはcreate-vueで作成したVue 3のTypescript

最低限のユニットテストの定義

ビジネスロジック(データを変換する処理)のテストを最低限のユニットテストと定義する
「APIのレスポンス」から「画面の変数」に変換する処理
「画面の変数」から「APIのパラメータ」に変換する処理
「画面の変数」から「「画面の変数」に変換する処理
など

画面の変数は、リストビュー・入力フォーム・チャート・地図など

コンポーネントもデータの変換を行っていますが、今回は対象外とします
どのコンポーネントをどこまでテストするかの判断が難しいため(要件しだいにもなにもなる)

個人的に画面の部品のコンポーネントは、振る舞いに関するユニットテストは必ず行うべきだと思う
振る舞いはボタンコンポーネントの場合は、押下時にイベントを発生する、propsに指定した値をラベル名で表示するなど

考え方と実装方法

「ケント・ベックの設計ルール」の考え方をもとに、テストの作成と実行がもっとも楽になるようにする

https://bliki-ja.github.io/BeckDesignRules

レスポンスや入力フォームを変換するステートレス関数を作成し、その関数に対してユニットテストを行う
変換処理はなるべくステートレス関数に集めて、関数を使用するコンポーネント側では変換処理を少なくする
ステートレス関数に対するテストはコンポーネントのテストよりも簡単にテストできる

チャートや地図を表示する複雑な画面の場合も
引数が「APIレスポンスや画面の変数」で戻り値が「チャートや地図のデータ型」のステートレス関数とテストコードのシンプルな形となる

ユーザ一覧画面のサンプルコード

レスポンスと画面のデータ型

// src\apis\UserListType.ts
/** ユーザ一覧APIのレスポンス */
export type UserListResponse = {
  userId: number
  firstName: string
  lastName: string
  email: string
  countryId: number
  contractCount: number
}[]

// src\pages\users\list\UserListType.ts
/** ユーザ一覧画面のリストの1行分 */
export type UserListItem = {
  userId: number
  fullName: string
  email: string
  countryName: string
  contractCount: string
}

レスポンスから画面用のデータに変換するステートレス関数

// src\pages\users\list\userListHelper.ts
import type { UserListResponse } from '@/apis/UserListType'
import type { UserListItem } from '@/pages/users/list/UserListType'

export const userListResponseToUserListItems = (
  userListResponse: UserListResponse
): UserListItem[] => {
  const numberFormat = new Intl.NumberFormat('ja-JP')

  return userListResponse.map(
    (userResponse): UserListItem => ({
      userId: userResponse.userId,
      // 姓と名を結合して氏名とする
      fullName: `${userResponse.lastName} ${userResponse.firstName}`,
      email: userResponse.email,
      // 国IDから国名に変換する(値は適当)
      countryName:
        userResponse.countryId === 1 ? 'アメリカ' : userResponse.countryId === 2 ? '日本' : '-',
      // 契約数をカンマ区切りの文字列に変換する
      contractCount: numberFormat.format(userResponse.contractCount)
    })
  )
}

ステートレス関数へのテストコード

// src\pages\users\list\userListHelper.test.ts
import { describe, it, expect } from 'vitest'
import { userListResponseToUserListItems } from '@/pages/users/list/userListHelper'

describe('userListHelper', () => {
  describe('userListResponseToUserListItems', () => {
    it('レスポンスに値が揃っている前提条件で、変換した場合は、正常に変換できること', () => {
      const result = userListResponseToUserListItems([
        {
          userId: 1,
          firstName: 'yamada',
          lastName: 'taro',
          email: 'yamada@example.com',
          countryId: 1,
          contractCount: 1234567
        },
        {
          userId: 2,
          firstName: 'kimura',
          lastName: 'taro',
          email: 'kimura@example.com',
          countryId: 2,
          contractCount: 123
        }
      ])
      expect(result).toMatchObject([
        {
          userId: 1,
          fullName: 'taro yamada',
          email: 'yamada@example.com',
          countryName: 'アメリカ',
          contractCount: '1,234,567'
        },
        {
          userId: 2,
          fullName: 'taro kimura',
          email: 'kimura@example.com',
          countryName: '日本',
          contractCount: '123',
        }
      ])
    })
  })
})

画面コンポーネント

// src\pages\users\list\UserList.vue
<template>
  <table>
    <thead>
      <th>氏名</th>
      <th>メール</th>
      <th>契約数</th>
      <th></th>
    </thead>
    <tbody>
      <tr v-for="userListItem in userListItems" :key="userListItem.userId">
        <td>{{ userListItem.fullName }}</td>
        <td>{{ userListItem.email }}</td>
        <td>{{ userListItem.contractCount }}</td>
        <td>{{ userListItem.countryName }}</td>
      </tr>
    </tbody>
  </table>
</template>

<script lang="ts" setup>
import axios from 'axios'
import { ref, onMounted } from 'vue'
import { userListResponseToUserListItems } from '@/pages/users/list/userListHelper'
import type { UserListResponse } from '@/apis/UserListType' 
import type { UserListItem } from '@/pages/users/list/UserListType'

const userListItems = ref<UserListItem[]>([])

onMounted(async () => {
  // レスポンスの検証(zod)は省略
  const response = await axios.get<UserListResponse>('/users')
  // ステートレス関数を呼び出す
  userListItems.value = userListResponseToUserListItems(response.data)
})
</script>

ユーザ編集画面のサンプルコード

レスポンスと画面のデータ型

// src\apis\UserGetType.ts
/** ユーザ取得APIのレスポンス */
export type UserGetResponse = {
  userId: number
  firstName: string
  lastName: string
  email: string
  countryId: number
  contractCount: number
}

// src\apis\UserPutType.ts
/** ユーザ更新APIのパラメータ */
export type UserPutRequestBody = {
  userId: number
  firstName: string
  lastName: string
  email: string
  countryId: number
  contractCount: number
}

// src\pages\users\edit\UserEditType.ts
/** ユーザ編集画面の編集フォーム */
export type UserEditForm = {
  userId: number
  firstName: string
  lastName: string
  email: string
  countryId: number | null
  contractCount: string
}

レスポンスから編集フォーム、編集フォームからパラメータに変換するステートレス関数

// src\pages\users\edit\userEditHelper.ts
import type { UserGetResponse } from '@/apis/UserGetType'
import type { UserPutRequestBody } from '@/apis/UserPutType'
import type { UserEditForm } from '@/pages/users/edit/UserEditType'

export const userGetResponseToUserEditForm = (userGetResponse: UserGetResponse): UserEditForm => {
  return {
    userId: userGetResponse.userId,
    firstName: userGetResponse.firstName,
    lastName: userGetResponse.lastName,
    email: userGetResponse.email,
    // 国はselectでで入力し、未入力時にnullになる
    countryId: userGetResponse.countryId,
    // 契約数はinput type="text"で入力するため文字列とする
    contractCount: String(userGetResponse.contractCount)
  }
}

export const userEditFormToUserPutRequestBody = (
  userEditForm: UserEditForm
): UserPutRequestBody => {
  if (userEditForm.countryId === null || isNaN(Number(userEditForm.contractCount))) {
    throw new Error('編集フォームの変換は検証後にコールする想定。検証に誤りがある')
  }

  return {
    userId: userEditForm.userId,
    firstName: userEditForm.firstName,
    lastName: userEditForm.lastName,
    email: userEditForm.email,
    countryId: userEditForm.countryId,
    contractCount: Number(userEditForm.contractCount)
  }
}

ステートレス関数へのテストコードは基本的に一覧画面と同じのため省略

// src\pages\users\edit\userEditHelper.test.ts

画面コンポーネント

// src\pages\users\edit\UserEdit.vue
<template>
  <form @submit="submitHandler">
    <label><input type="text" v-model="userEditForm.firstName"></label>
    <label><input type="text" v-model="userEditForm.lastName"></label>
    <label>メール<input type="email" v-model="userEditForm.email"></label>
    <label><select v-model="userEditForm.countryId">
        <option :value="null"></option>
        <option :value="1">アメリカ</option>
        <option :value="2">日本</option>
      </select>
    </label>
    <label>契約数<input type="text" v-model="userEditForm.contractCount"></label>
    <button>保存</button>
  </form>
</template>

<script lang="ts" setup>
import axios from 'axios'
import { ref, onMounted } from 'vue'
import { userGetResponseToUserEditForm, userEditFormToUserPutRequestBody } from '@/pages/users/edit/userEditHelper'
import type { UserGetResponse } from '@/apis/UserGetType'
import type { UserEditForm } from '@/pages/users/edit/UserEditType'


const userEditForm = ref<UserEditForm>({
  userId: 0,
  firstName: '',
  lastName: '',
  email: '',
  countryId: null,
  contractCount: '',
})

onMounted(async () => {
  // サンプルのためidは1で固定
  const response = await axios.get<UserGetResponse>('/users/1')
  userEditForm.value = userGetResponseToUserEditForm(response.data)
})

const submitHandler = async () => {
  // 入力フォームの検証がある想定、省略
  // ...

  const userPutRequestBody = userEditFormToUserPutRequestBody(userEditForm.value)
  // putのレスポンスの型は省略
  await axios.put('/users/1', userPutRequestBody)
}
</script>

Discussion