はじめに
ついに弊社にも単体テストを導入しようという意識が生まれました! とても嬉しい!
とはいえテストを実装した経験はないため、必死にキャッチアップをしています。そこで、学んだことを記事としてアウトプットしようと思います。
誤りがあった場合はご指摘いただけるとありがたいです。
良い単体テストを構成する4つの柱
単体テストの考え方/使い方によると、良い単体テストを考えるにあたって以下の4点が重要ということです。
- 退行に対する保護
- リファクタリング耐性
- 迅速なフィードバック
- 保守のしやすさ
それぞれ詳しく見ていこうと思います。
1. 退行に対する保護
退行とは
新しい機能を追加する際、直接関係のなさそうな実装部分にまで影響が及び、意図せず既存の機能が壊れてしまう現象を「退行」と呼びます。たとえば、以下のような単純な関数があったとします。
const plus = (a, b) => {
return a + b;
}
今回の改修で、この関数が「2つの引数の合計を2倍にして返す」ように変更されたとしましょう。
const plus = (a, b) => {
return (a + b) * 2;
}
この改修自体は正しいと思われますが、実はplus()は数値の加算だけでなく、引数として渡された文字列を連結する役割も担っていた場合、文字列の結合という既存の機能が壊れてしまう恐れがあります。これがいわゆる退行です。
良い単体テストは、このような退行を検知し、既存の機能が変更されていないことを保証する役割を果たします。具体的にどれだけ退行に対する保護ができているかは、以下の点で評価できます。
-
テスト時に十分な量のプロダクションコードが実行されているか
テストで実行されるコード量が多ければ多いほど、広範囲な退行を検知することができます
-
テスト対象のプロダクションコードの複雑さ
複雑なロジックは、重要な機能である場合が多いため、入念なテストが必要です
-
対象となるドメインの重要度
システムの中心となる機能(例:人件費計算システムにおける人件費計算ロジック)は、テストする価値が非常に高いです。逆に、取るに足らないコードはテストのコストと効果を天秤にかける必要があります。
2. リファクタリングへの耐性
リファクタリングとは
リファクタリングとは、外部から観察可能な振る舞い(インプットとアウトプット)を変更せずに、内部の実装を改善する作業です。たとえば、変数名の変更や、一部のメソッドを別のクラスに切り出すなどが該当します。
耐性を保つために
リファクタリングを行っても、観察可能な振る舞いが変わらなければ、テスト結果も同じであるべきです。もしテストが内部実装に強く依存していると、リファクタリングを正しく行えているのにテストが失敗する「偽陽性」や、逆に実装に不具合があってもテストが通ってしまう「偽陰性」が発生してしまいます。
そのため、テストコードではあくまで入力と出力といった外部に見える振る舞いを確認するようにし、内部の詳細な実装には依存しないよう心がける必要があります。リファクタリングへの耐性がしっかり備わっていると、次のような効果が得られます。
- 既存機能に問題が発生しても、テストによって早期に警告を受けることができる。
- プロダクションコードを変更した際にも、退行が起こっていないと自信を持って確認できる。
3. 迅速なフィードバック
単体テストは、1つのユニット(機能)に対して行われるため、通常は高速に実行されるべきです。テストの実行に時間がかかりすぎると、開発者はテストを実行する頻度が減ってしまい、結果として不具合の早期発見ができなくなります。
迅速なフィードバックが得られることで、バグが混入してもすぐに検出でき、修正サイクルを短縮することが可能となります。
4. 保守のしやすさ
テストコードもプロダクションコードと同様に、メンテナンスが必要なコードです。保守のしやすさを判断する指標として、以下の点が挙げられます。
-
テストケースの理解のしやすさ
他の開発者がテストケースを読んで、意図を理解しやすいかどうか
-
テストの実行の容易さ
たとえば、テストの実行に特別な環境(テスト用データベースなど)が必要な場合、手間がかかり保守性が低下する可能性があります
4つの柱をすべて満たすテストは存在するのか?
結論として、4つの柱すべてを完全に満たすテストは存在しません。特に「退行に対する保護」「リファクタリングへの耐性」「迅速なフィードバック」にはトレードオフの関係があり、すべてにおいて最大の効果を発揮するのは難しい状況です。
以下、いくつかのテストの例を挙げて、各項目についての評価を見ていきます。
E2Eテスト(エンドツーエンドテスト)
システム全体が連携して動作する環境で行うテストです。
| 項目 | 評価 | 理由 |
|---|---|---|
| 退行に対する保護 | ◎ | 多くのプロダクションコードを実際に実行するため、退行を広範囲に検知できます。 |
| リファクタリングへの耐性 | ◎ | 観察可能な振る舞いに基づいてテストされるため、偽陽性が出にくいです。 |
| 迅速なフィードバック | ✖️ | テスト実行に時間がかかるため、迅速なフィードバックは期待しにくいです。 |
取るに足らないテスト
テスト対象のコードが極めて単純で、バグが発生する可能性が低い場合のテストです。
| 項目 | 評価 | 理由 |
|---|---|---|
| 退行に対する保護 | ✖️ | 実行するコードが少なく、重要なロジックを担っていないため、退行検知の効果は限定的です。 |
| リファクタリングへの耐性 | ◎ | 内部の実装に依存しないため、変更しても偽陽性が出にくいです。 |
| 迅速なフィードバック | ◎ | テスト実行が非常に高速です。 |
例:
class User
{
public string $name;
public function __construct(string $name)
{
$this->name = $name;
}
}
class UserTest extends TestCase
{
public function testUserClass()
{
$user = new User("田中");
$this->assertEquals("田中", $user->name);
}
}
壊れやすいテスト
テストケースが実装の詳細に過度に依存している場合、わずかな変更でテストが失敗してしまうリスクがあります。
| 項目 | 評価 | 理由 |
|---|---|---|
| 退行に対する保護 | ◎ | プロダクションコードを十分に実行していれば、退行は検知できます。 |
| リファクタリングへの耐性 | ✖️ | 内部実装の変更に敏感であり、少しの修正で偽陽性が発生します。 |
| 迅速なフィードバック | ◎ | テスト実行自体は高速です。 |
例:
class User
{
public function getUserQuery()
{
$query = "SELECT 'id', 'name' FROM user WHERE ...(省略)";
return $query;
}
}
class UserTest extends TestCase
{
public function testGetUser()
{
$user = new User();
$expected = "SELECT 'id', 'name' FROM user WHERE ...(省略)";
$this->assertEquals($expected, $user->getUserQuery());
}
}
この例では、テストが内部のSQLクエリ文字列という実装の詳細に依存しているため、実装の微妙な変更でテストが失敗してしまうリスクが高くなっています。
どのようにバランスを取るのか
リファクタリングへの耐性は、基本的に「あり」か「なし」かの二択であり、部分的に備えるということは難しいため、常に最大限の耐性を持たせることが求められます。そのため、実際に調整が必要なのは「退行に対する保護」と「迅速なフィードバック」のバランスです。単体テストでは、後者である迅速なフィードバックを優先することが多いですが、同時にテストケース自体の保守性も高める努力が必要です。
結局のところ、テストケースの価値は4つの柱がどれだけバランスよく備わっているかによって決まります。どれか1つでも効果がゼロになってしまうと、テスト全体の価値が大きく低下してしまいますので、常にバランスに注意しながら設計・実装を行うことが重要です。