単体テストの考え方/使い方の重要ポイントまとめてみた
こんにちは、小さな開発会社でエンジニアをしてるコード・ドットです。
突然ですが、みなさん単体テストは書いてますか?
現職はお世辞にもテストをしっかり書いてるとはとても言えない環境で、申し訳程度に書いてるような状況(おまじない)でした。このままではいけないと思いソフトウェアテスト、その中でも最も重要になる単体テストについてしっかり学ぼうと思いました。学習に使った単体テストの考え方/使い方
が大変勉強になったのでこちらの書籍で個人的に重要だと思った部分をまとめていきたいと思います!!
非常に読み応えのある書籍で丸1週間かかりましたが、今後のエンジニアキャリアにおいて間違いなく資産になると確信しました!
結論
単体テストは実装の詳細に結びつけてはいけない これは本書が一貫して伝えてる単体テストの原則です。本書には単体テストを書く上で重要なテクニックがこれでもかと言うぐらい紹介されてますが、そのほとんどがこの原則を守るためのテクニックになります。それを踏まえた上でこの後の章では、単体テストの定義や単体テストの質を評価する方法なども紹介していきます。
なぜ単体テストが必要なのか?
これを知っておく意義は非常に高いと思います。
テストがなくてもソフトウェアは動きます。わざわざ面倒な作業を増やしてまで行うからにはそれなりの動機が必要だと思います。そしてその答えがソフトウェアの成長を持続可能なものにするためです。
※画像はこちらの記事から引用させていただきました。
上記のグラフを見ていただいた通り開発初期はテストなしの場合は費やす時間が短い一方でテストありの場合は費やす時間が多くなっています(この段階ではテストに価値を感じずらい)。しかしソフトウェアが肥大化するにつれてテストなしの方は費やす時間が大きく跳ね上がる一方で、テストありの費やす時間の増加は一定です。短距離選手と長距離選手に良し悪しはありませんがソフトウェア開発においては長距離選手の方が長い目で有利なようです。
カバレッジではテストの質を評価できない
テストの話になると必ずカバレッジという言葉が出てきます。
カバレッジにもいろいろな手法がありますが、基本的にはテストコード内で実行されるプロダクションコードの量が多ければ多いほどカバレッジが高いと判断されます。しかし、ここには大きな問題があります。
以下は、phpunitを使ったサンプルです。
// テスト対象クラス
class Example {
public function getStatus(int $value): string
{
if ($value > 0) {
return "Positive"; // 今回のテストケースでは実行されてない
} else {
return "Non-Positive";
}
}
}
valueの値に応じてPositiveもしくはNon-Positiveを返すシンプルなクラスです。
// テストケース
use PHPUnit\Framework\TestCase;
class ExampleTest extends TestCase {
public function testGetStatus()
{
$example = new Example();
// Positive のケースのみテスト
$this->assertEquals("Positive", $example->getStatus(1));
}
}
上記はExampleクラスのgetStatusメソッドのPositiveが返却されるのをテストしています。
現在のテストではPositiveが返却される部分しかプロダクションコードとして実行されないため網羅率が100%に至っていませんが以下のようにするとどうでしょうか?
// リファクタリングしたExample
class Example {
public function getStatus(int $value): string {
return $value > 0 ? "Positive" : "Non-Positive";
}
}
ここでは三項演算子を使って処理を1行にしています。これによってNon-Positiveのケースをテストしてなくても全てのプロダクションコードが実行されてると見なされるためカバレッジは100%になってしまいます。これがカバレッジだけではテストの質を判定できない理由です。
単体テストの定義
単体テストの定義は以下の3つで構成されています。
-
単体(unit)と呼ばれる少量のコードを検証する
- 関数やメソッド、クラスといった小さな単位を対象にしたテスト
- コード単位での動作が正しいかを検証
-
実行時間が短い
- 実行時間が長いテストは、通常「統合テスト」や「E2Eテスト」に分類される。
-
隔離された状態で実行される
- テストケース同士や外部環境から完全に独立している必要がある。
- 隔離により、テスト結果が他の要素に影響されない状態を保証する。
逆にいうと上記3つの内1つでも条件を満たしてない場合は単体テストではなくなります。
1と2はイメージがつきますが、3の 「隔離」とはテスト同士が完全に独立した状態を指します。
この「隔離」を実現するためには、テスト同士の依存を取り除く必要があります。
主な隔離対象は以下です。
-
共有依存
- テストケース同士がデータベースや共有リソースを通じて影響を与え合わないようにする。
-
プロセス外依存
- ファイルシステムやネットワーク、現在時刻などの外部リソースへの依存を取り除く。
本書ではこれらの依存はモックやスタブを使い隔離を行うことを推奨しています。
単体テストの王道AAAパターン
単体テストを書く際には基本的に以下の3つのフェーズで成り立ちます。
- 準備(Arrange)
- 実行(Act)
- 確認(Asseart)
これらの頭文字をとってAAAパターンと呼びます。
お恥ずかしながら私はAAAパターンという名前を初めて聞いたのですが、実際にテストケースを書くと自然とのこのパターンになるはずです。
例えば、簡単な計算クラスをテストするPHPのサンプルコードを見ていきましょう
// 簡単な計算を行うクラス
class Calculate {
public function add(int $a, int $b): int
{
return $a + $b;
}
}
こちらは渡ってきたパラメーター同士を足し合わせるシンプルなクラスです。
// Calculateクラスのテスト実行クラス
class CalculateTest extends TestCase {
public function testAdd()
{
// 準備
$a = 10;
$b = 20;
$calculate = new Calculate();
// 実行
$result = $calculate->add($a, $b);
// 確認
$this->assertEquals(30, $result);
}
}
上記のように準備フェーズでメソッドに渡す変数や依存クラスの準備を行い、実行フェーズで対象のシステムを実行します。そして最後に検証をしています。非常にシンプルで見やすいですね。恐らく皆さんが普段書いてるテストもこんな感じになってると思います。
単体テストにおいて回避しなくてはならないこと
単体テストの構成AAAパターンをを知った上で単体テストで回避しなければいけないことは以下になります。
- 同じフェーズを複数用意する
- if文をテスト内で利用する
- 実行フェーズのコードが複数行で構成されてる
同じフェーズを複数用意する
準備 -> 実行 -> 確認 -> 実行 -> 確認
もしもあなたが書いたテストがこのような構成の場合注意が必要です。
1つのテストケースに同じフェーズが複数回出てくる場合、高確率で複数の振る舞いをテストしようとしています。これは単体テストの定義から外れた結合テストになってしまいます。1つのテストケースで1つの振る舞いを検証するようにテストを分割しましょう。
if文をテスト内で利用する
テスト内でif文を利用するデメリットは以下のようになります。
- シンプルに可読性が下がる
- 複数の振る舞いを検証しようとしてる
実行フェーズのコードが複数行で構成されてる
個人的に一番ハッとさせられた部分になります。
例えば、物流倉庫で在庫を管理するシステムをテストする例を見ていきましょう。
// 実行フェーズが2行のアンチパターン
$warehouse->shipment();
$warehouse->removeInventory();
上記は出荷を行った後に、倉庫の在庫数を減らす処理を行っています。
一見問題なさそうですが、これは単体テストのアンチパターンになります。
主な問題点は一つのオペレーションで複数の振る舞いを行っていることです。要はプロダクションコードがカプセル化されてないということです。本来、「出荷」というアクションと「在庫を減らす」というアクションは1つの振る舞いに集約されてないといけません。なぜなら出庫はしたけど在庫を減らし忘れたという事態が起きてしまう可能性があるからです。
今回の場合、shipmentメソッド内でremoveInventoryが実行されるようにリファクタリングすることで解決できるようになります。
良い単体テストを構成する4本の柱
先に言わせてくださいここ超重要です。
良い単体テストを構成する4本の柱を知ることで、自分自身で良い単体テストと悪い単体テストを判断できるようになります。個人的にここが本書が他のテスト本とは一線を画す部分だと思いました。
良い単体テストを構成する4本の柱は以下になります。
- 退行(regression)に対する保護
- リファクタリングへの耐性
- 迅速なフィードバック
- 保守のしやすさ
退行(regression)に対する保護
ソフトウェア開発における退行とはバグのことを指します。新規機能を追加した影響で、既存の機能でバグが発生してしまうことがありますが、それをテスト実行時に発見しやすいテストケースは退行(regression)に対する保護が備わっていると判断できます。
退行に対する保護がどれくらい備わっているかを判定する方法
- テスト時に実行されるプロダクションコードの量
- そのコードの複雑さ
- そのコードが扱っているドメインの重要性
リファクタリングへの耐性
ここで言う「リファクタリング」とは、テストコードではなく、既存のプロダクションコードの改修を指します。
リファクタリングへの耐性があるテストとは、プロダクションコードをリファクタリングした際にも、テストが正しく機能し、偽陽性を出さないテストのことです。
偽陽性が起こす問題
- 開発者がテスト結果を重要視しなくなる
- テストへの信頼が落ちる
これは私自身も実際に陥ってしまい、テストを書くモチベーションが下がってしまった記憶があります。
アラートが出ることに慣れてしまい、アラートを消すためにテストを書くという本末転倒な事態になってしまいました。
迅速なフィードバックと保守のしやすさ
迅速なフィードバックのメリット
- テストケースの増加(網羅率)
- 結果 -> 改善サイクルを高速で回せる
続いて保守のしやすさの指標を見ていきましょう
保守のしやすさは以下の指標で測ることができます。
- テストケースを理解することがどのくらい難しいのか?
- テストケースが肥大化していれば理解に時間がかる
- テストを行うことがどのくらい難しいのか?
- プロセス外依存などがある場合、気軽に実行することが難しい -> 保守しずらい
単体テストの質を評価する
単体テストの質は単体テストが持つ4つの柱をどれだけ備えているかの度合いを掛け算することで算出できます。
- 退行(regression)に対する保護
- リファクタリングへの耐性
- 迅速なフィードバック
- 保守のしやすさ
しかし、これらを完全に備えたテストケースは存在しません。
理由は、退行(regression)に対する保護とリファクタリングへの耐性、迅速なフィードバックの3本の柱は同時に成り立たない性質を持っているからです。これは3本の柱のうち2本を優先することで残り1本の柱が欠落してしまうというイメージです。
このトレードオフを理解した上でどの柱を優先したら良いか?
本書ではこのように、リファクタリングへの耐性を最大限に備えた上で、迅速なフィードバックと退行に対する保護はバランスを見て調整することを推奨しています。
私たちが単体テストを書く上で重要なのは、リファクタリングへの耐性と保守のしやすさを十分に備えたテストである必要があるわけですね。とはいえ、退行に対する保護や迅速なフィードバックのどちらかが完全に欠落したテストは無価値になるため一定備えるように調整しなくてはいけません。
このように、4本の柱を用いることで現在のテストの質を評価できるようになります。
実装の詳細をテストしてはいけない
前回のセクションではリファクタリングへの耐性を最大化することが重要だということがわかりました。
そしてそれがまさに本書が主張してる単体テストを実装の詳細と結びつけてはいけないという結論に繋がることになります。なぜなら実装の詳細をテストした場合、リファクタリングへの耐性は失われ、壊れやすいテストになってしまうからです。
例えば、先ほどの物流倉庫で在庫を管理するシステムを例にしてみましょう。
class Warehouse {
private int $quantity;
public function __construct(int $quantity)
{
// 在庫数をセット
$this->quantity = $quantity;
}
public function shipment()
{
$this->removeInventory();
// 実行結果をtrue or falseで返却
}
public function getQuantity(): int
{
return $this->quantity;
}
private function removeInventory()
{
// 在庫を減らす処理
}
}
こんな感じでshipmentを実行すると内部的にremoveInventoryで在庫を減らす処理があったとします。在庫数はクラス変数quantityとして保持するようにしています。
※今回はイメージを付けてもらうことが目的のため、実装の詳細については割愛します。
// 悪い例
class WarehouseTest extends TestCase {
public function testShipment() {
// 準備
$sut = new Warehouse(10); // 倉庫に在庫を10件セット
// 実行
$sut->shipment(); // 出荷処理を実行
// 確認
$this->assertEquals(9, $sut->getQuantity()); // 在庫数が9になっているか
}
}
このテストでは出荷という振る舞いではなく、出荷処理内で行なった在庫変化をテストしています。つまり実装の詳細をテストしていることになります。
では良い例を見てみましょう
// 良い例
class WarehouseTest extends TestCase {
public function testShipment() {
// 準備
$sut = new Warehouse(10); // 倉庫に在庫を10件セット
// 実行
$result = $sut->shipment(); // 出荷処理を実行
// 確認
$this->assertTrue($result);
}
}
このテストは出荷するという振る舞いをテストしています。これなら、在庫を減らすロジック変更やリファクタリングを行っても簡単にテストが壊れる心配はありません。このようにして、実装の詳細ではなく、振る舞いをテストすることで、リファクタリングへの耐性のあるテストケースが作成できます。
ホワイトボックステストとブラックボックステスト
テスト手法には大きく分けて、ブラックボックステストとホワイトボックステストという2つの種類があります。それぞれに特徴があり、目的に応じて使い分けられます。
ブラックボックステストの特徴
- システムの内部構造を見ずに検証する
- システムの仕様や要求から作成される
ホワイトボックステストの特徴
- システムの内部構造を検証する
では、どちらを採用すべきか?
ここまで読んでいただいた方なら、もう答えは見えたはずです。そう、採用すべきはブラックボックステストです。ブラックボックステストは、「どのように(how)実現しているか」ではなく、「何を(what)実現しているか」に焦点を当てます。ホワイトボックステストは退行に対する保護には強いですが、リファクタリングへの耐性は低いです。「単体テストの質を評価する」セクションでも触れたように、単体テストではリファクタリングへの耐性を備えることが重要です。良い単体テストを構成する4本の柱のうち1本でも欠けてしまえば、テストの価値は大幅に下がります。リファクタリングに耐えられないテストは、開発の妨げとなり、むしろ技術的負債を生む原因にもなりかねません。
だからこそ、ブラックボックステストを採用してリファクタリングへの耐性を担保することが不可欠なのです。
感想
いや〜本当に読み応えのある本でした!
今回紹介してませんが、以下のようなトピックも紹介されてるので気になる方はぜひ一度本書を手にとってみてはどうでしょうか?
- モックの使い方
- 結合テスト
- リファクタリングテクニック
本書で得た知識は間違いなく、今後のエンジニア人生において資産になり得ると確信しています。
また、本書を読む上でドメイン駆動開発についての知見があるとより深く理解ができると思うので、私が以前ドメイン駆動開発について書いた記事も参考にしてみてください。
重い腰上げてドメイン駆動設計入門してみた
Discussion