最低限のユニットテストを行うためのVue.jsの設計ルール
前提
フロントエンドには多くのユニットテストがある
案件の特性や工数により、多くの種類の自動テストを行うのが難しい場合がある
ユニットテストの取捨選択を誤りうまくいかなかった反省のため、最低限のユニットテストについてまとめる
サンプルコードはcreate-vueで作成したVue 3のTypescript
最低限のユニットテストの定義
ビジネスロジック(データを変換する処理)のテストを最低限のユニットテストと定義する
「APIのレスポンス」から「画面の変数」に変換する処理
「画面の変数」から「APIのパラメータ」に変換する処理
「画面の変数」から「「画面の変数」に変換する処理
など
画面の変数は、リストビュー・入力フォーム・チャート・地図など
コンポーネントもデータの変換を行っていますが、今回は対象外とします
どのコンポーネントをどこまでテストするかの判断が難しいため(要件しだいにもなにもなる)
個人的に画面の部品のコンポーネントは、振る舞いに関するユニットテストは必ず行うべきだと思う
振る舞いはボタンコンポーネントの場合は、押下時にイベントを発生する、propsに指定した値をラベル名で表示するなど
考え方と実装方法
「ケント・ベックの設計ルール」の考え方をもとに、テストの作成と実行がもっとも楽になるようにする
レスポンスや入力フォームを変換するステートレス関数を作成し、その関数に対してユニットテストを行う
変換処理はなるべくステートレス関数に集めて、関数を使用するコンポーネント側では変換処理を少なくする
ステートレス関数に対するテストはコンポーネントのテストよりも簡単にテストできる
チャートや地図を表示する複雑な画面の場合も
引数が「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