hacomono TECH BLOG

フィットネスクラブやスクールなどの顧客管理・予約・決済を行う、業界特化型SaaS「hacomono」を提供する会社のテックブログです!

画面のテストコードを無理しない書き方で書く



こんにちは。UX部のがーみーです。最近は植物が好きになり、花やら観葉植物を置くようになりました。みているだけで気持ちが落ち着きます。
この記事ではフロントエンドのテストコードについて、書かれるようになってきて出てきた問題を整理し、無理をしない書き方を探ります。

フロントエンドテストが書かれるようになった

私が入社した2023年はフロントエンドのテストコードがあまり書かれていませんでしたが、さまざまな取り組みによって、フロントエンドにも普通にテストコードが書かれるようになりました。いまでは Testing Library を利用して、ページやUIコンポーネントなど、画面に対するテストが書けるようになっています。テストを書いておきたいという時に普通に使えるものがあり、ある程度どういうテストを書くべきかという共通認識があるのは助かります。
一方、常に安心して使えているかというとそうでもない部分もありそうです。必要な時に気軽に書けるといいのですが、何かが引っかかってフロントエンドのテストはいまはやめとこうとなるケースも目にします。私自身、書くのが億劫なときもあります。

データ用意するの大変問題

後入れでテストコードを書こうとするとみんな苦労するのが、データ準備です。私も苦労しました(具体的にどんな感じでテストコードを書いていたかはこちらの記事に書きました)。基本的にウェブアプリケーションであればページ実装があり、そのページはバックエンドからデータをもらってそれを表示するわけですが、これを再現するのがなかなか大変です。そのページから投げているリクエストを調べてデータを用意して、モックしてあげる必要があります。
MSWのハンドラーがすでにあるとか、なんらかの方法ですでにダミーデータが返されるようになっていれば話は違いますが、テストコードを書くようになる前に作られた既存機能では、そうはなっていません。まずは自分でデータを用意する必要があります。既存機能の少しの改修のためにその手間を費やすのは億劫なので、テストはやめとこう…となりがちです。

メンタルモデルぶれる問題

また、テストコードを書く際の考え方はさまざまです。Test Pyramid でユニットテストを大事にする考えもあれば、Testing Trophy だといってインテグレーションテストを厚くしようという考えもあります。ユニットテストでは細かく分けられたコンポーネントと責任が限定されたなかでのテストで済みますが、結合部分の不具合は検出できなくなります。一方、インテグレーションテストではよりユーザーが見るものに近づき信頼性は上がりますが、いろんなものに依存することになり、データのセットアップが大変になりがちです。
いろいろな人の意見を読むほど、良いテストコードの書き方や分類を完璧に定義するのは難しいことがわかります。私自身、テストコードを書き始めたときはこの定義に関心がありましたが、結局、ガチガチに定義するよりも、テストをする粒度は開発者の裁量で都度決めたほうが信頼できるテストになるのではと考えるようになりました。

1000行を超えて増えていくページコンポーネントへのテスト

困難を乗り越えてテストコードを書いた結果、ページコンポーネントへのテストコードは多いもので1000行、2000行と増えていってしまいます。機能が複雑で、インテグレーションテストでちゃんと書こうとすると、こうなってしまうのは仕方がないことだと思います。
行数が多いと困るのが見通しの悪さです。そのコンポーネントの修正者としてテストコードを開くと、今自分が手を入れた部分のテストコードがどこにあるのかを探るのも一苦労となります。しっかりテストケースが網羅できて品質は担保できても、保守が難しいテストコードとなってしまいます。

無理をしない書き方は?

前述した問題の原因は、ページレベルのコンポーネントに対してしっかりテストコードを書くことのような気がしています。インテグレーションテストを厚くしようという考えとダミーデータの不十分さがあいまって、前述したような問題を引き起こしています。もう少し気軽に、普通に便利なツールとして Testing Library を使っていくにはどうすればいいのでしょうか。
先にも書いたように、細かくユニットテストをするのと、複数のコンポーネントをつなげてページのような単位でインテグレーションテストにするのでは、それぞれに良し悪しがあります。私はどちらかというと、データのセットアップの問題が解決されるのであれば、インテグレーションテストを厚くしていきたい派ですが、そうでない状況であればユニットテストのほうが気軽に書けて良いと思っています。
気軽にテストを書けるようにするには、テスト対象を小さくすることです。1つの引数があり、わずかな分岐があり、値が返ってくる。それくらいの関数のテストだったら、とてもスラスラと書けそうです。

// 簡単な関数の例。
// isEven.ts
function isEven(i: number): boolean {
  return i % 2 === 0
}
// isEven.test.ts
it('1を渡すとfalse', () => {
  expect(isEven(1)).toBe(false)
})
it('2を渡すとtrue', () => {
  expect(isEven(2)).toBe(true)
})

画面に対するテストをこういうものにするためには、コンポーネント分割が必要になります。無理に全てをインテグレーションテストに寄せず、代わりに小さいコンポーネントを小さくテストすることです。

コンポーネント分割とテストコードの例

よくあるTODOリストアプリケーションを題材に考えてみます。TODOというデータに対して基本のCRUD操作があるだけのものですが、雰囲気を出すために業務ソフトっぽいナビゲーションもつけています。

コーディングエージェントに頼んで適当に作ってもらったTODOアプリ

1ページにつき1つのコンポーネントをつくってそこに実装を全部入れてもらっています。これを分割しつつテストを書いていきます。

pages/todos/index.vue
画面の一部に手を入れるシチュエーションで考える

今回はこの画面の一部に手を入れるシチュエーションとして、タスク一覧の完了タスクに完了日時を表示する場面を考えてみます。
ただし、このページにはテストコードがまだ存在しておらず、このページの全体を成立させるには、メインのTODOタスクのデータに加えて、ヘッダーに表示されているユーザー情報や通知情報なども必要になります。
修正者としては、この小さな修正のためにいちから必要なダミーデータを用意するのは気が重いです。ページ全体の表示というよりは、自分が手を加えた部分や影響しそうな範囲についてはテストコードを残しておきたいです。

責務を分ける

そこで、コンポーネントを分けます。1つのアイテムに対するテストは1つのアイテムを表示するコンポーネントに対するテストとして書けるように、以下のようにTODOを1件表示する責務を持ったコンポーネントをTodoItemとして切り出しました。

pages/todos/index.vue
components/TodoItem/TodoItem.vue


TODOの1件分をTodoItemとする。

やりたいことを小さいテストにする

今回の改修内容としては完了日時を表示すればいいので、テストとしては以下のようにすればよさそうです。

// TodoItem.test.ts
describe('未完了のタスクの場合', () => {
  const todo = buildTodo({ completed: false, completedAt: null })
  
  it('完了日時を表示しない', () => {
    render(TodoItem, { props: { todo } })
    expect(screen.queryByText('完了', { exact: false })).not.toBeInTheDocument()
  })
})
describe('完了しているタスクの場合', () => {
  const todo = buildTodo({ completed: true, completedAt: '2025-09-19T15:24:00+0900' })
  
  it('完了日時を表示すること', () => {
    render(TodoItem, { props: { todo } })
    expect(screen.getByText('完了:2025/09/19 15:24')).toBeVisible()
  })
})

これで最低限の仕事はできました。少なくとも、分岐を入れるところを間違えて未完了のタスクに「完了:」と表示してしまうことはなさそうです。これくらいの量であれば改修のついでに追加するのは現実的です。

小さいテストでは難しい場面

上記は小さいテストで気持ちよく書けるケースですが、逆に小さいテストでは不安になる場面もあります。たとえば、タスク管理ツールのメイン機能であるチェックボックスにチェックを入れた時の挙動です。これをTodoItemへのテストとして表現すると以下のようになります。

// チェックを入れる機能の確認を小さいテストで書いた場合。
// TodoItem.test.ts
describe('未完了のタスクの場合', () => {
  const todo = buildTodo({ completed: false, completedAt: null })
  
  it('チェックを入れることができる', async () => {
    const { emitted } = render(TodoItem, { props: { todo } })
    const checkbox = screen.getByRole('checkbox')
    await userEvent.click(checkbox)
    // TodoItem から toggleCheck イベントが飛んでくれば、チェックをいれたことにする
    expect(emitted()).toHaveProperty('toggleCheck')
  })
})

イベントを発火することの確認だけでは、TODOリストが本当に機能しているのかを確認できません。そのイベントを親コンポーネントがつかまえて、対象のTodoの状態を更新し、完了したタスクとしてユーザーに表示してくれないと、機能として成立しません。
こういう場合は、その状態管理をしている親コンポーネントに対してテストを書くと良さそうです。

// pages/todos/index.test.ts
it('未完了のタスクにチェックを入れることができる', async () => {
    // ページコンポーネントをrenderする。データのモックはMSWなど別でやっている想定
  render(TodoIndexPage) 
  // チェック対象のアイテムを特定
  const todo = await screen.findByRole('listitem', { name: 'Learn Nuxt 3' })
  const todoCheckbox = within(todo).getByRole('checkbox')
  // 操作前はチェックが入っていない
  expect(todoCheckbox).not.toBeChecked()
  // ユーザー操作
  await userEvent.click(todoCheckbox)
  
  // 操作後はチェックが入る
  expect(todoCheckbox).toBeChecked()
})

ユーザー操作に近くなり、安心感があります。データのモックの手間はかかりますが、ここは外せないとか、ここは結合部分が不安、という機能のところではやる価値があると思います。

まとめ

テストコードを書くのは大事ですが、書く際のルールが厳しすぎたり前準備が大変すぎると、結局使われなくなってしまいます。今回書いたように、小さいテストで十分であれば小さく、大きく書かないと不安なところは大きく、という選択肢があると良いと考えています。
依存する全てのデータを用意するのが大変であればコンポーネントを切り出してそのなかのユニットテストをする。結合部分に不安があったり、あとから内部をリファクタしそうなところはちょっと頑張ってインテグレーション具合を高めて(たとえばページ層のテストにして)安心感を得る。そういう判断を都度しながら、フロントエンドテストを無理なく使っていければ良いなと思います。


💁 関連記事