Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
💯

「単体テストの考え方/使い方」が主張するたった一つのこと

2024/07/08に公開

はじめに

読書会をやってみました

オープンロジのエンジニアのrikuto(@riku929hr)です。
社内で「単体テストの考え方・使い方」というテストに関する有名な本の読書会を実施し、1回1時間、15回の開催を経て読み切りました。

https://book.mynavi.jp/ec/products/detail/id=134252

原著は「Unit Testing Principles, Practices, and Patterns」で、Oreilly Learning Platformでも読むことができます。

https://www.manning.com/books/unit-testing

400ページにもわたる本で、読み切るのには大変な手応えがありました。
たぶん読書会のようなものを開催しない限り、僕自身読みきれなかったかもしれません。
しかし読んでみると、著者が主張しているのはごくシンプルなことでした。

この記事のタイトル、ちょっと嘘ついてます

タイトルには、「主張するたった一つのこと」としていますが、細かく言えば1つではありません。
この本が主張することはそれ相応にありますが、この記事では筆者が最も主張したいことに重点をおき、関連する文献の内容や、僕の解釈を加えながら、いい単体テストについて考えていきます。

たった一つのこと、それは…

実装の詳細ではなく、振る舞いをテストせよ!

ということです。

順を追って見ていきましょう。

なんのためにテストするのか

まずは根本的なことを押さえておきましょう。この本の一番はじめに言われていることです。

この本に限らず言われていることですが、テストが無い・テストの質が悪いコードでは、ある一定の成長段階に達したときに自信を持ってコードを変更することが困難になり、品質や開発速度が指数関数的に落ちていきます。
そうなると、新機能のリリースやバグフィックスが遅れ、プロダクト・事業の成長や存続に大きな影響を及ぼすことになります。

質が高く充実したテストがあれば、この品質や成長速度の低下を抑え、自信を持ってコードに変更を加えることができ、継続的にプロダクトを成長させることができます。

将来の成長のために質の高いテストを書き、さらにそれを開発サイクルに組み込むのが重要だということを前もって認識しておく必要があります。

単体テストの「単体」とは

まず、書籍から単体テストの特徴として挙げられている性質を引用します。

  • 「単体(unit)」と呼ばれる少量のコードを検証する
  • 実行時間が短い
  • 隔離された状態で実行される

「単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略」p28

僕自身、本を読む前からずーっと気になっていたことですが、単体テストの「単体」とは何を指すのでしょうか?
これには以下のように2つの学派があり、見解が分かれています。そしてこれはテストダブル(平たく言えばモック)の使い方に影響します。

古典学派[1] ロンドン学派
単体のスコープ(隔離の対象) テストケース 一つの関数・クラス
テストダブルの使い方 できる限り使わず、利用は管理下にない依存のみに限定する テスト対象のコード以外はすべてモックにする

古典学派では、単体はテストケースを指します[2]。上記で述べている「隔離」は書籍から引用した言葉で、テストケースを隔離するとは、テストケースそれぞれが、お互いに影響を及ぼさないことを指します。つまり、同時実行が可能で決定性が高い(何度やっても同じ実行結果になる)ということです[3]
これは逆に言えば、DBへのアクセスを必要とするような、テストの実行が他のテストに影響を及ぼすものはすべて統合テストに分類されることを意味します。オープンロジはサーバーサイドにphpを利用していますが、めちゃくちゃざっくり言えばphpのプロセスで完結するテストが単体テスト、みたいな考え方でしょうかね。また、単体テストの性質の中に、「実行時間が短い」とありますが、たとえばDBへのR/Wが発生するようなテストは実行時間がかかってしまうので、実行時間の観点からも整理できそうです[4]

一方のロンドン学派では、単体は一つの関数やクラスを指します。古典学派では隔離の対象がテストケースだったのに対し、ロンドン学派ではそれが関数やクラスになるというわけです。そのため、ある関数から呼び出されるすべての関数は、テストダブル(モック)に置き換えることで単体テストを行います。これも同様に逆を言えば、テスト対象から呼び出される関数・クラスをテストダブルに置き換えずにテストするものが統合テストに分類されます。

じゃあどっちの立場を取ればええんや??という話になりますが、ここでは古典学派の立場をとっています。
これは先に述べた、振る舞いをテストするということに直結します。これについては次で述べていきます。

実装の詳細に結びつくテストは偽陰性・偽陽性を生む

テストにおける陽性(positive)・陰性(negative)

陰性(negative)・陽性(positive)という言葉は、コロナ禍で一気に広まったのでなんとなくイメージが湧く人も多いでしょう。
ソフトウェアテストの文脈では、以下のような意味合いになります。

term description
陰性(negative) 期待通りテストが成功する
陽性(positive) 期待通りテストが失敗する
偽陰性(false negative) 本来落ちるはずのテストが成功してしまう
偽陽性(false positive) 本来成功するはずのテストが失敗してしまう

品質に悪影響をもたらすのは、偽陰性・偽陽性であることは明確かと思います。
実装の詳細に結びつくテストは、この偽陰性、偽陽性を生んでしまう原因になってしまうということです。

ここでこの記事のタイトルに戻ります。
テストが実装の詳細に結びつくとは、具体的にどのようなことを指しているのでしょうか?
それは、テストで検証する内容が、ロジックの中身になっているということです。
例えば、テスト対象のコードから呼び出される関数が想定通りに呼ばれていることを確認する、などがそれにあたります。
そして実は、その最たる例がテストダブル(簡単のため以下モックと呼びます)だということです。

実装の詳細をテストするとは

実装の詳細に結びつくとはどういうことか、簡単な例をあげてみていきます。
たとえば、合計金額が100円以上なら10円割引するというロジックがあったとします。
(コードはphp8、phpunitです。以下のコードは説明に最低限のものを書いてるので、いろいろご了承ください。。)

class Checkout
{
    public function __construct(
        private DiscountService $discountService
    ) {
    }

    public function checkout(int $total): void
    {
        if ($this->discountService->needsDiscount($total)) {
            // 割引処理
            $checkoutPrice = $this->discountService->discount($total);
        }

        // 何らかの処理
        // ...
    }
}

class DiscountService
{
    private const MINIMUM_DISCOUNT_PRICE = 100;
    private const DISCOUNT_AMOUNT = 10;

    public function needsDiscount(int $total): bool
    {
        return $total >= self::MINIMUM_DISCOUNT_PRICE;
    }

    public function discount(int $total): int
    {
        return $total - self::DISCOUNT_AMOUNT;
    }
}

上記のコードで、Checkout の単体テストをモックを使って書くと(ロンドン学派の単体テスト)、以下のようになります。

class CheckoutTest extends TestCase
{
    public function test_100未満の場合は割引されない()
    {
        $discountServiceMock = $this->createMock(DiscountService::class);
        $discountServiceMock->expects($this->once())->method('needsDiscount') // needsDiscountメソッドが1度だけ
            ->with(99) // 引数99で呼ばれることを確認して
            ->willReturn(false); // falseを返すようにmockする
        $discountServiceMock->expects($this->never()) // discountメソッドが呼ばれないことを確認
            ->method('discount');

        $checkout = new Checkout($discountServiceMock);
        $checkout->checkout(99);
    }

    public function test_100以上の場合は割引される()
    {
        // 以下略…

このテストでは、Checkout::checkoutの処理で割引処理が走らないことを、DiscountServiceの関数が呼ばれないことを確認することによって担保しようとしています。
実装の中身のロジックを確認する、すなわち実装の詳細に結びつくテストになっているということです。

このコードのメリットとしては、DiscountServiceの実装を知らなくても、Checkoutのテストが書けるということです。モックを使うロンドン学派のテストのメリットといえます。

リファクタリングによって偽陽性を生んでしまう例

一方で、これによってどんな問題が起こるのか考えてみます。
例えば、needsDiscountのメソッド名を変更した場合はどうでしょうか。
ロジックは変えずに関数名を変えただけなのに、テストが失敗してしまうことになります。
これがいわゆる偽陽性で、結果は正しいのにテストが落ちるという事態を招いてしまいます。
こうした「嘘の警告」が積み重なっていくと、開発者のいろんなモチベーションを下げてしまうほか、真の陽性を見落としてしまうことにつながってしまいます。

仕様の変更によって偽陰性を生んでしまう例

上記は偽陽性を生む例でしたが、似たような理由で偽陰性を引き起こします。
たとえば、10円割引するというロジックに加え、タイムセールとして無条件に20円割引する仕様変更があったとしましょう。
この場合、checkoutメソッドは以下のように修正されます。

    public function checkout(int $total): void
    {
        if ($this->discountService->needsDiscount($total)) {
            // 割引処理
            $checkoutPrice = $this->discountService->discount($total);
        }

+       $checkoutPrice = timeSale($checkoutPrice); // タイムセールで20円割引

        // 何らかの処理
        // ...
    }

かなり雑な修正であることは目を瞑っていただきまして()、ここでは最終的な金額が、$discountService->discount()の後に変更されていることがポイントです。
この場合、最終的な金額はタイムセール割引された金額にならなければいけないはずですが、test_100未満の場合は割引されない()は特に修正を加えなくてもテストを通過してしまいます。
本来であれば、「100未満の場合でも20円割引される」ことが正しいので、このテストは落ちる必要がありますが、落ちてくれないので「偽陰性」に該当します。

再掲:以下のコードはタイムセール処理を追加しても、テストが通ってしまう
    public function test_100未満の場合は割引されない()
    {
        // モックによってdiscount()が呼ばれないことだけを確認しているため、
        // タイムセールの仕様変更が入ったとしても追従しづらい
        $discountServiceMock = $this->createMock(DiscountService::class);
        $discountServiceMock->expects($this->once())->method('needsDiscount')
            ->with(99)
            ->willReturn(false);
        $discountServiceMock->expects($this->never())
            ->method('discount');

        $checkout = new Checkout($discountServiceMock);
        $checkout->checkout(99);
    }

つまり、プロダクションコードの変更とともに、(テストが落ちることに頼ること無く)自力でテストコードを修正する必要があるということで、開発の負荷を非常に高めてしまうことは容易に想像がつくでしょう。
ちなみに、このようなプロダクションコードの変更にテストコードが追従しない事によって起こる偽陰性のことを、モックドリフトと呼ぶそうです[5]

品質と開発速度を維持するためには、このようなことを起こさないようにしていかなくてはいけない、ということなんですね。。。

振る舞いをテストするとは

上記のコードを、振る舞いをテストするようにリファクタしてみましょう。

再掲ですが、振る舞いは簡単に言えば関数が返す結果です。
まず、購入処理(Checkout)からドメインロジックである割引計算(DiscountService)を切り離し、一つのクラスに閉じ込めてしまいます。

class DiscountCalculator
{
    private const MINIMUM_DISCOUNT_PRICE = 100;
    private const DISCOUNT_AMOUNT = 10;

    private function needsDiscount(int $total): bool
    {
        return $total >= self::MINIMUM_DISCOUNT_PRICE;
    }

    public function calculate(int $total): int
    {
        if ($this->needsDiscount($total)) {
            return $total - self::DISCOUNT_AMOUNT;
        }
        return $total;
    }
}

ここで説明を楽にするために、リファクタ後のクラスの命名を変えさせてください。
割引計算処理をするものということで、DiscountCalculatorという名前にしてみました。
そしてneedsDiscountcalculate内で呼んでしまい、privateにしています。いわゆるカプセル化ってやつですかね。
このようにした背景には、Checkoutでは金額の操作を行わず、購入処理の手続きだけ行いたいからです(これについて述べると長くなっちゃうので割愛)

そして割引計算のテストは、このDiscountCalculatorのテストを書くことで達成できます。

class DiscountCalculatorTest extends TestCase
{
    public function test_100未満では割引されない()
    {
        $priceCalculator = new PriceCalculator();
        $this->assertSame(99, $priceCalculator->calculate(99));
    }

    public function test_100以上で10割引される()
    {
        $priceCalculator = new PriceCalculator();
        $this->assertSame(90, $priceCalculator->calculate(100));
    }

    // その他異常値や境界値のテストなど
    // ...
}

ここで注目してほしいのはテストコードで検証している内容です。
リファクタ前のコード(DiscountService)では、mockの関数が正しく呼ばれている/呼ばれていないことを確認していたのに対し、リファクタ後のコード(DiscountCalculator)では、結果だけに着目し、返ってきた値が条件を満たしているかを検証しています。
この、結果だけに着目して検証することが、"振る舞いをテストする"ことになるわけです。

そして、CheckoutDiscountCalculatorに金額を渡し、割引の決定や処理を委ねたあと、結果(割引後の金額)を受け取ることができます。

class Checkout
{
    public function __construct(
        private DiscountCalculator $discountCalculator
    ) {
    }

    public function checkout(int $totalPrice): bool
    {
        $checkoutPrice = $this->discountCalculator->calculate($totalPrice);
        // 何らかの処理
        // ...
    }
}

Checkoutは割引計算の結果を受け取るだけのシンプルな作りです。
割引計算の責任がDiscountCalculatorに完全に移ったことによって、Checkoutのテストコードは、// 何らかの処理 (例えば決済処理など)のテストケースを書けば十分という状態になりました。

振る舞いがテストできるコードを書こう

ここまで、振る舞いをテストすることについて簡単な例を挙げて見てきました。
ここで、振る舞いをテストすることについてもう一つ言えることは、どんなコードでも振る舞いがテストできるわけではなく、振る舞いをテストできるコードにする必要があるということです。

「単体テストの考え方/使い方」p.149には次のような言葉が書かれています。

API をきちんと設計すれば、単体テストは自然と質の良いものになります。

「単体テストの考え方/使い方」p.149

質の高いテストを書くためには良いコードを書きましょうということです。
もちろん、いつ何時でもきれいな設計にできるとは限りませんが、できる限り質の高いコードを書いていきたいです。

おまけ:モックは必要悪

以下のブログから引用しましたが、「モックは必要悪」という表現がとてもしっくりきました。

https://blog.8-p.info/ja/2021/10/12/mock/

ちなみに、「単体テストの考え方・使い方」では、モックを使うときのtipsとして、以下のようなことを挙げています。
いずれも、テストを実装の詳細からできる限り遠ざけるためのものになっています。

  • モックの利用は「管理下にない依存」のみに限定する
  • 具象クラスではなく、interfaceをモックにする
  • できる限りドメインロジックをモックにせず、ロジックから最も離れた部分をモックにする

まとめ:振る舞いをテストしよう!

ここでは一例[6]として、モックを利用したテストを例に取り、振る舞いをテストすることについて考えました。

まとめると、

  • 実装の詳細ではなく振る舞い、すなわち最終的な結果を検証するようにすることで、「質の良い」テストになる
  • 振る舞いをテストするためには、テストコードだけでなくプロダクションコードの設計も重要

ということです!!

さいごに

この記事では本の内容のごく一部を取り上げましたが、内容の濃いいいいいい本でした。
テストの関数の命名方法やコメントの書き方などの細かなエッセンスも多数紹介されています。

詳しくは本をみてください!

めちゃめちゃ雑ですが、この記事で書いているコードはこちらです。php8.3で動かしてます。

https://github.com/riku929hr/unit-test-example

余談

読書会の開催を経て、オープンロジの開発チームでテストコードに関する書き方の規約をまとめたスタイルガイドを策定しました。
内容はこの本をベースにしながら、他のテスト関係の書籍も参考にしつつ、今のコードの状態や開発課題を考えながら作ってます。
今では社内全体でルールが適用され、これまで属人的だったテストの書き方にある程度統一感をもたせていけるのではないかと期待しています。

これについては、またどこかで紹介できる機会があればいいなと思っています。

ではでは。

脚注
  1. デトロイト学派とも ↩︎

  2. 正確には「隔離の対象がテストケース」という表現のほうが正しいですが、簡単のために単体を主語にしています。 ↩︎

  3. 参考:https://gihyo.jp/dev/serial/01/savanna-letter/0005 ↩︎

  4. ちなみに、「レガシーコード改善ガイド」には「実行に0.1秒もかかる単体テストは遅い単体テストである」(p.16)と記載されています。 ↩︎

  5. 参考:https://gihyo.jp/dev/serial/01/savanna-letter/0004 ↩︎

  6. その他の例としては、わかりやすいところだとprivateメソッドのテストなどがあります。 ↩︎

OPENLOGI Tech Blog

Discussion