フォーム読み込み中
この記事は、ソフトバンク アドベントカレンダー 2025 22日目の記事です
初めまして!新卒入社でフロントエンド開発を担当している小林です。
突然ですが、皆さんは自信を持って「テストを理解している」と言えますか?
私はつい最近まで、正直に言うと「No」でした。
入社後のIT研修でも、「システム開発におけるテストの重要性」を繰り返し聞きました。しかし正直なところ、当時の私は、「開発後には必ず動作確認をするのだから、それで十分ではないか」と考え、テストの必要性を実感できていませんでした。過去に、大学の授業でチーム開発をしたときには、締切に間に合わせるためにテストをおざなりにした結果、意味のないテストコードになってしまいました。
(社内の研修でチーム開発をしたときも、テストコードを書くように推奨されていたものの、結局ほとんど書きませんでした…)
そんな研修が終わり、部署に配属されて3ヶ月が経った頃、担当しているプロダクトをリファクタリングするWG(ワーキンググループ)を作ることになりました。
そのWGの立ち上げの中で、ソースコードをリファクタリングするためには、テストコードを整備する必要があるという話になりました。なぜリファクタリングにテストコードが必要なのか、直観的には必要そうかもな...と感じたものの、本質的な理解はできていませんでした。
このような経緯から、私は約2ヶ月間、ソフトウェア開発のテストについて調べ、その学びをこの記事にまとめました。
今回は、テストが必要な理由、フロントエンド開発のテストトロフィー、そしてリファクタリングWGを通して気づいたことについて紹介します。
テストの意義を理解するために、私は「単体テストの考え方/使い方」という本を読みました。
本のタイトルには単体テストと書かれていますが、プロダクトに対して価値のあるテストを作るための考え方が、開発者の目線で体系的にまとめられている印象でした。
この本の中で、良い単体テストを構成するためには4本の柱があると提唱されていました。
これらについて紹介します。
退行とは、機能の追加・変更後に、既存機能が動かなくなることです(デグレード)。テストを整備することで、変更が影響しそうな機能のバグを事前に検知できます。
特に、複数チームで並行開発する大規模システムでは、全ての実装を把握するのは困難です。
私たちのプロダクトもアジャイル開発で、1スプリント(2週間)ごとに複数のスクラムチームが並行して機能を追加しており、他チームが実装した詳細までは把握できません。
しかし、テストがあれば、詳細を知らなくてもデグレードによる不具合を防げます。
私はこれまで小規模開発中心でテストの重要性を実感できませんでしたが、大規模開発においてテストが「退行に対する保護」としてソフトウェア品質を支える役割を担っていると気づきました。
こちらもテストの目的としては、退行と同様に、プロダクションコードのリファクタリングの際、機能が壊れずに仕様通りに動作しているかを確認することです。
ただし、この柱は退行への保護とは性質が異なり、テストに対する信頼性を担保します。
例えば、以下のように税込価格を表示するようなコードがあるとします。
<template>
<div class="price-container">
<span class="price-label">税込価格:</span>
<span class="price-value">{{ taxIncluded }}円</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
price: number;
}>();
const taxIncluded = computed(() => Math.round(props.price * 1.1));
</script>
親コンポーネントからpriceを渡し、taxIncludedで税込価格を計算し表示する構成になっています。
このテストを書いてみましょう。
// ProductPrice.test.ts
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/vue';
import ProductPrice from './ProductPrice.vue';
describe('ProductPrice', () => {
it('悪いテスト例:DOM構造とクラス名に依存', () => {
const { container } = render(ProductPrice, {
props: { price: 1000 },
});
// クラス名に依存
const label = container.querySelector('.price-label');
expect(label!.textContent).toBe('税込価格:');
const value = container.querySelector('.price-value');
expect(value!.textContent).toBe('1100円');
});
});
ice: number;
}>();
const taxIncluded = computed(() => Math.round(props.price * 1.1));
</script>
このようなテストはクラス名が壊れてしまうと、テストが通らなくなります。例えば、以下のようにprice-labelとprice-valueを1つに統合します。
<span class="price">税込価格: {{ taxIncluded }}円</span>
この場合、表示内容の仕様は変わっていなくても、使用していたクラスがなくなったり、ページの構造(DOM構造)が変わったりすると、テストは失敗してしまいます。
このようなテストを壊れやすいテストと呼びます。
以下は同じ内容で壊れにくいテストの例です。
describe('ProductPrice', () => {
it('良いテスト例:税込価格が正しく表示される', () => {
render(ProductPrice, {
props: { price: 1000 },
});
// ユーザーが画面で見るテキストをそのまま検証
expect(screen.getByText('税込価格: 1100円')).toBeInTheDocument();
});
こちらは、DOM構造に関係なく、画面に '税込価格: 1100円' が表示されているかをテストします。
このようなテストは、仮にクラス名が変更されても画面上に表示された内容しか検証しておらず、コード上の内部処理に依存していないため、壊れにくいテストとなります。
この壊れにくさが、テスト結果への信頼につながります。
壊れやすいテストが多いと、
という状況に陥ります。
その結果、テストへの信頼性が失われ、テストを頼りにリファクタリングすることが難しくなり、コードを安全に整理できなくなるため、かえってバグや技術的負債を増やしてしまいます。
テストの実行時間は、テストの実施回数に影響します。
実行時間が長いとテスト頻度が減り、バグ発見が遅れる原因となります。開発後のバグ発見は、原因特定や影響調査、修正の手戻りを大幅に増加させます。
テストに対する適切な環境とサイクルがあれば、機能追加直後にバグを発見でき、調査・修正の負担を軽減できます。
テストの保守コストを表します。
テストケースが増えると、テストコードの量が増え、読みにくく、変更しづらくなります。ただし、安易に減らすと、品質の担保ができなくなります。
この本の中で、筆者はリファクタリングへの耐性を最大限に高めつつ、退行に対する保護と迅速なフィードバックをトレードオフとして捉えるべきだと主張しています。
では、なぜ筆者はリファクタリングの耐性を最大化することを推奨しているのでしょうか?
それは、プロジェクトが成長後期(保守開発)に入ると、リファクタリングへの耐性が非常に重要になるからです。
一般的にテストは、本番環境でバグを出さないための「退行に対する保護」が最も重要だと考えられがちです。
しかし、プロジェクトが長期化し、コードの量が膨大になるにつれて、コードの改善(リファクタリング)の必要性が高まります。ここで重要になるのが「リファクタリングへの耐性」です。
テストがリファクタリングへの耐性を欠いていると、コードをきれいにしようとするたびに、大量のテストが偽陽性として失敗します。
これにより、開発者は「テストは信頼できない」「テストを直すのが面倒だ」と感じるようになり、次のような悪循環に陥ります。
プロジェクトの成長後期では、このリファクタリングを躊躇してしまうことによる「負債の蓄積」が、偽陰性(実際のバグを見逃すこと)と同じくらい、あるいはそれ以上に大きな問題となるのです。
また、リファクタリング耐性は「ほどほど」に持たせることが難しく、ユーザーから見える「外部の振る舞い」だけを検証すれば耐性は高くなり、クラス名などの「内部構造」まで検証しようとすると耐性が低くなるという、シンプルな二択に近い性質があります。
そのため、筆者は長期的なプロジェクトの健全性を保つために、この「リファクタリングへの耐性」を最大限に考慮すべきだと主張しているのです。
この本を読んで、「プロダクションコードをリファクタリングするためには、テストコードの整備が重要」ということを深く理解することができました。私の担当しているプロダクトは、開発後期で保守に差し掛かっているため、なおさらリファクタリングやテスト整備が必要なんだなと思いました。
長くなりましたが、まとめです。
ソフトウェアテストにおけるテストケースを説明する際に、以下のテスト・ピラミッドがよく用いられます。
横軸は各テストレベルのテストケース数、縦軸は「保守コスト・実行時間・信頼性」を表します。上に行くほど、
というイメージです。
単体テストを主とし、E2Eテストは重要な部分に絞って実施するのが理想的とされています。
一方で、フロントエンド開発ではトロフィー型が推奨されています。
この形は静的解析、単体テスト、結合テスト、E2Eテストで構成され、フロントエンドのテストでは結合テストが一番テストケースが多くなることが理想的であることが提唱されています。
どうしてこのような形が理想的なのか、簡単に説明します。
静的解析:特にフロントエンドでよく用いられる JavaScriptなどは、型の不一致などの標準的なチェックを備えていないため、静的解析の恩恵が他の言語より大きくなります。そこで、土台として静的解析を厚くしています。
※静的解析とは、プログラムを実行することなく、型の破綻や規約違反などソースコードの構文や構造を解析してバグを防ぐ仕組み。
単体(Unit)テスト:ピラミッド型より小さくなります。その理由は、フロントエンドはロジックが少ないことが挙げられます。基本的にフロントエンドがUIの操作や画面状態の機能を担うため、バックエンドよりも条件分岐や状態変化など複雑なロジック処理が少ないです。そのため、ロジックを検証する意味合いが強い単体テストは、そこまで重要度が高くありません。
E2Eテスト:信頼性は高いですが保守コストが高く実行時間が長いため、テストケース数をできるだけ絞るべきです。
結合(Integration)テスト:単体とE2Eの間に位置し、信頼性と保守コスト・実行速度のバランスが良く、フロントの部品が正しく連携して動作するかを効率よくテストできるため、結合テストのテストケースを増やしています。
フロントエンドは一番ユーザーに接触する部分の機能を担っています。ユーザーは内部的な処理に関心がありません。そのため、テストをするときには、ユーザーから見える情報に近い形の出力=画面の表示・振る舞いに関心を持つべきとされています。
テストトロフィーに関しては、次の記事を参考にしています。
私は現在WGの活動で、テスト戦略や設計、運用ガイドラインなどを作成を行う中で得られた3つの気づきを紹介します。
※あくまで個人の意見なので参考程度に見ていただけると嬉しいです。
テストにおける課題を考えるときに、ピラミッドやトロフィーのように現状のテスト構成を形として可視化することは、課題の特定に非常に有用でした。
一般的にアンチパターンとされる「アイスクリームコーン型」とは異なり、私たちの現状は「気球型」に近いことが分かりました。
この「気球型」は、以下の記事を参考に私たちが名付けたものです。
この記事でバルーン型が提唱されているのですが、かなり私たちの現状に近いなと思い、参考にさせていただきました。
この図は、1つのテストファイルに単体テストと結合テストが混在し、境界が曖昧(雲のようにモヤっとしている)という課題を表しています。
可視化したことで、「テストの不足や責務の分離ができていない」という具体的な改善ポイントが明確になり、テスト設計の指針を立てやすくなりました。
「リファクタリングへの耐性(壊れにくいテスト)」を高めるためには、「How(どう実装したか)」ではなく「What(何が出力されたか)」に着目する必要があります。
Howなテスト(ホワイトボックス)
Whatなテスト(ブラックボックス)
「リファクタリングをするためにテストする」でのコード例に例えると、Howなテストは壊れやすいテスト(悪いテスト)を示し、Whatなテストは壊れにくいテスト(良いテスト)を表しています。
フロントエンドにおいては、「ユーザーには内部処理は見えず、画面の振る舞い(出力)しか見えない」という点からも、Whatなテストとの親和性が高いと言えます。
もし「機能追加のたびにテストが落ちる」という課題がある場合、テストが内部処理の確認になっていないか、「How」になっていないかを見直すことが重要です。
テスト対象を「状態管理」「操作」「画面状態」の3つに分類した際、最優先すべきは「画面状態(UIの出力)」です。
フロントは、「入力(状態+操作)」を受け取り、「出力(画面表示)」を返す仕組みです。出力であるUIが仕様通りであれば、内部の状態管理や操作も正しく機能していると言えます。
一方、内部の状態管理やロジックをどこまで個別にテストすべきかは、プロダクトの特性によって異なります。状態が複雑なアプリケーションでは個別にテストした方が確実ですが、表示が中心のアプリケーションであれば、画面を通した確認だけで十分な場合もあります。
そのため、私の個人的な意見としては、以下のように優先度をつけると整理しやすくなりました。
UI(出力)のテストを最優先する
画面に正しく反映されているかを確認することで、内部ロジックも間接的に検証する。
ロジック単体のテストは、複雑な場合のみ行う
複雑な計算や非同期処理など、UI経由では検証しきれない場合に限り、単体テストで補完する。
私が担当するプロダクトでは、一時保存した内容を画面に反映させる機能があります。このような機能は、画面を通してテストすれば、間接的に内部の状態管理も検証できます。そのため、内部の状態管理に関するテストの優先度は低いと判断しました。
ただし、テスト対象によっては全ての状態やロジックが UI 経由で検証できるわけではありません。複雑な計算や非同期処理は、単体テストを用いた方が検証しやすく、保守性も向上します。そこはプロダクトが持つ機能や性質次第の部分もあるかなと思います。
このように、まずは UI で確認すべきことを押さえ、そのうえで必要に応じて状態管理やロジックを個別にテストするという考え方が、テストの優先度を決めるうえで有効です。
むやみにすべてのレイヤーを網羅しようとすると、テストコードが膨れあがり、保守性が低下してしまいます。どこを UI テストで担保し、どこを単体テストで押さえるのかを意識的に切り分けることが重要です。たとえば、「画面にそのまま反映される状態」は UI テストで担保し、「複雑な計算ロジック」だけは単体テストで押さえる、といった切り分けを意識するのが大切です。
今回は、テストが必要な理由、フロントエンド開発のテストトロフィー、そしてリファクタリングWGでの気づきついてご紹介しました。
私たちのプロダクトは、現状の実装が複雑化しており、仕様調査に膨大なコストがかかっています。このコストを下げ、調査や修正に掛かる時間を減らし、より早く品質の良いアプリをリリースすることで、最終的に利益に繋げることを見据えて活動しています。
そのための第一歩が「テストのリファクタリング」です。
テストが整備されると、プロダクションコードを安全にリファクタリングできる
⇒コードが整理されると、仕様調査のコストが下がる
⇒結果として、素早いリリースと高品質なサービス提供が可能になる
入社当初は「テスト=動作確認」程度にしか捉えていませんでしたが、今では、テストがビジネス価値を持続的に生み出していくのに欠かせない土台なのかなと思います。
一方で、どれだけ理論が正しくても、「テストの重要性」に対する価値観をチーム全員で共有していなければ、せっかく書いたテストも形骸化してしまいます。
だからこそ、ただコードを書くだけでなく、テストを本当に価値のある工程にするために、今後も試行錯誤を続けていくつもりです。
最終的にそれが会社の利益につながると信じて、まずはチームのみんなが迷わず、自信を持って開発できる環境づくりに貢献していきたいです。
ソフトバンク アドベントカレンダー 2025 の23日目の記事もお楽しみに!
条件に該当するページがございません