LoginSignup
12
9

More than 3 years have passed since last update.

ふるまいの集合体をユニットテストする

Last updated at Posted at 2020-12-17

はじめに

本稿では、ソフトウェアのユニットテストを書くときに意識したい考え方をご紹介します。

ユニットテストの定義1は様々あるでしょうが、一般的には以下のように認識されているかと思います。

  • ソフトウェアを構成する個々のユニットを、開発者自身でテストすること。

ここでのユニットとは具体的に何を指すのでしょうか。オブジェクト指向言語で書かれたプログラムであれば、クラスでしょうか。それとも、複数のクラスからなるコンポーネントでしょうか。

ソフトウェアに期待されるふるまい(=仕様)を提供する単位に対してユニットテストを書くようにしましょう。そのふるまいは一つのオブジェクトによって提供されることもあれば、複数のオブジェクトの協調によって提供されることもあります。
この単位のことをふるまいの集合体と呼びます。

ふるまいの集合体とは?

ふるまいの集合体という言葉は、書籍『レガシーコードからの脱却 ―ソフトウェアの寿命を延ばし価値を高める9つのプラクティス2』の10章で出てきます。

「10.3.2 ふるまいの集合体」より引用:

私が「ユニットテスト」の「ユニット」と言った場合、多くの開発者が想定しているメソッド、クラス、モジュール、関数などのようなエンティティを指すものではない。ユニットとはふるまいの単位、つまり独立した検証可能なふるまいのことだ。
…(中略)…
あらゆる観察可能なふるまいが、それに紐づくテストを持つべきであることを意味する。

同書の11章ではテストコードによってふるまいを明示するプラクティスについて語られるのですが、シンプルなサンプルコードで説明されているため、実用的なアプリケーションで具体的にどう書くべきかは迷うところです。

サンプルアプリケーション

本稿では、「架空のECサイトにおける、注文料金計算のユースケース」を題材として説明を行います。ビジネス要求にもとづく仕様は以下と仮定します(細かく読まなくて大丈夫です)。

  • 注文金額計算
    • 小計(明細金額の合計)にクーポン割引を適用し、送料を加えたものを課税対象額とする。
    • 課税対象額に10%を乗じた金額を消費税額とする(サンプルなので税率は一律とす)。
    • 課税対象額に消費税額を加えた金額が注文金額となる。
  • 送料計算
    • 小計(明細金額の合計)が3000円未満の注文の送料は500円。ただし送付先住所が離島の場合は1,200円。
    • 小計が3000円以上の場合は送料無料。ただし送付先住所が離島の場合は700円。
    • 顧客がプレミアム会員の場合は、住所が離島の場合も含めて常に送料無料。
  • クーポン割引
    • クーポンは小計(明細金額の合計)に対して適用される。送料に対しては適用されない。
    • 割引後の金額がマイナスになることはない。

上記の仕様を実装したアプリケーションの構造は以下のクラス図のようになっています。(実際のソースコードはGitHubを参照してください。アプリケーションはJavaで書かれています)。

クラス図

  • CalculateOrderAmountUseCase はプレゼンテーション層から呼ばれる想定のコンポーネントです。書籍『Clean Architecture 達人に学ぶソフトウェアの構造と設計3』流にユースケースという名前を与えていますが、いわゆるアプリケーションサービスと考えてください。
  • OrderAddress などはドメインオブジェクト(エンティティ、値オブジェクト)です。 Order は書籍『エリック・エヴァンスのドメイン駆動設計4』の集約ルート5にあたります。(※本来は Product Customer も別の集約ルートとしてモデリングするのが正しいかもしれませんが、サンプルのため簡略化し一つの集約とします)。
  • OrderAmountCalculationService (注文金額計算サービス)、 ShippingFeeCalculationService (送料計算サービス) は同じくドメイン駆動設計におけるドメインサービスにあたるもので、ドメインオブジェクトには割り当てづらいビジネスルールを割り当てています。

ふるまいの集合体を考える

前述のサンプルアプリケーションを題材に、ふるまいの集合体、すなわち独立して検証したいふるまいの単位を考えてみます。

ドメインサービス

ドメインサービスはわかりやすいですね。それぞれのサービスが独立したビジネスルールを実現するので、そのまま検証対象となります。

ドメインサービス

例えば送料計算サービスのふるまいの検証は以下のテストコードとなります。

ShippingFeeCalculationSpec.groovy
    def "送料計算が正しい: #rank #address #price => #expectedFee"() {
        given: "所与のランク・住所の顧客による、所与の小計額の注文がある"
        def customer = CustomerHelper.aCustomerWithRankAndAddress(rank, address)
        def order = new Order(customer)
        OrderHelper.addOrderLineWithPriceAndQuantity(order, price, 1)

        when: "送料計算を実行したとき"
        def fee = sut.calculateFee(order)

        then: "期待どおりの送料を得られる"
        fee == BigDecimal.valueOf(expectedFee)

        where: "パラメータ"
        rank    | address       | price || expectedFee
        REGULAR | ADDR_MAINLAND |  2999 ||         500
        REGULAR | ADDR_MAINLAND |  3000 ||           0
        REGULAR | ADDR_ISLAND   |  2999 ||        1200
        REGULAR | ADDR_ISLAND   |  3000 ||         700
        PREMIUM | ADDR_MAINLAND |  2999 ||           0
        PREMIUM | ADDR_MAINLAND |  3000 ||           0
        PREMIUM | ADDR_ISLAND   |  2999 ||           0
        PREMIUM | ADDR_ISLAND   |  3000 ||           0
    }

Spock という Groovy 製のテスティング・フレームワークに馴染みのない方もいるかと思うので、簡単に解説しておきます。
Spock では BDD スタイル(GivenWhenThen)のフォーマットでテストを記述できる DSL が提供されています。

given: でラベル付けしたブロックでは、テストフィクスチャー6をセットアップします。

        given: "所与のランク・住所の顧客による、所与の小計額の注文がある"
        def customer = CustomerHelper.aCustomerWithRankAndAddress(rank, address)
        def order = new Order(customer)
        OrderHelper.addOrderLineWithPriceAndQuantity(order, price, 1)

ここでは、テストヘルパーを利用して、 Order オブジェクトを組み立てています。

次に when: のブロックでは SUT (System Under Test: テスト対象のソフトウェア)7を実行します。

        when: "送料計算を実行したとき"
        def fee = sut.calculateFee(order)

そして then: のブロックで検証を行います。

        then: "期待どおりの送料を得られる"
        fee == BigDecimal.valueOf(expectedFee)

Spock では then: のブロックはすべて検証コードとみなされるので、 Assert 関連のユーティリティは用いず、事後条件として成り立つべき(真と評価される)文を列挙すればよいのです。便利ですね。

実はこのテストメソッドはパラメーター化テスト8になっていて、実際のパラメーターは where: のブロックで与えています。

        where: "パラメータ"
        rank    | address       | price || expectedFee
        REGULAR | ADDR_MAINLAND |  2999 ||         500
        REGULAR | ADDR_MAINLAND |  3000 ||           0

ドメインオブジェクト

集約としてのふるまい

先ほどのドメインサービスは、引数で受け取った Order オブジェクトを利用して計算処理を行います。つまり Order に依存、 あるいは Order が提供するふるまいに依存しています。
この Order のふるまいは、 Order が単独で提供するものもありますが、多くのふるまいは関連するオブジェクト群との協調によって提供されます。よって、集約全体をふるまいの集合体として捉えましょう。

ドメインオブジェクト

とはいえ、テストを書く対象(SUT)は集約ルートである Order オブジェクトになります。ふるまいの集合体を検証するとはどういうことなのでしょうか?

例として Order オブジェクトの値引き額計算というふるまいを検証するとします。先にSUTの実装コードを載せます。

Order.java
    /**
     * 値引き額を計算する.
     * @return 値引き額
     */
    public BigDecimal calculateDiscount() {
        if (appliedCoupon == null) {
            return BigDecimal.ZERO;
        }
        BigDecimal subtotal = getSubtotal();
        return appliedCoupon.discount(subtotal);
    }

さて、オブジェクト指向における最小のプログラム単位である「クラス」をユニットと捉え、他から独立させてテストするというポリシーを取る場合、 Order が参照する Coupon オブジェクトへの依存を制御する必要が出てきます。
具体的には Coupon オブジェクトをテストダブル9にすげ替えます。以下のテストコードではテストダブルの一種であるモック10を利用しています。

OrderCalculationSpec.groovy
    def "値引き価格を取得できる: クーポン適用済 スタブ利用版"() {
        given: "定額クーポンが適用された注文"
        def sut = new Order(null) // このテストでは顧客は不要なのでnull
        OrderHelper.addOrderLineWithPriceAndQuantity(sut, 1000, 1)
        and: "モックの適用"
        def coupon = Mock(Coupon)
        sut.apply(coupon)

        when:
        def discount = sut.calculateDiscount()

        then:
        1 * coupon.discount(BigDecimal.valueOf(1000)) >> BigDecimal.valueOf(300)
        discount == BigDecimal.valueOf(300)
    }

以下のコードについては Spock の DSL に関する説明が必要でしょうね。

        then:
        1 * coupon.discount(BigDecimal.valueOf(1000)) >> BigDecimal.valueOf(300)

これは Coupon#discount メソッドが、 1000 を表す BigDecimal 値を引数としてちょうど一度だけ呼び出されることを期待し(左辺)、そのときは戻り値として 300 を表す BigDecimal 値を返す(右辺)、と読みます。
これにより、 リアルな Coupon の実装を用いることなく、 Order を独立してテストすることが可能となります。また、 OrderCoupon 間の相互作用(メソッド呼び出し)というふるまいも同時に検証されることになります。

一見よさそうですが、ここで注意したいのは、モックの多用は脆いテスト(Fragile Test)につながるリスクがあるという点です。 Coupon との相互作用は、値引き額計算という Order 集約に期待されたふるまいを実現するための設計判断の結果であり、外側( Order 集約の利用者)から見れば実装の詳細にあたります。何かのきっかけで実装に変更が入った場合にテストコードが壊れてしまう(動かなくなる)事態は避けたいものです。

『レガシーコードからの脱却』には以下のように書かれています。

「ユニット」が表現するのはふるまいだ。ふるまいが変わらないなら、テストを変える必要はない。

ですから、 モックではなく実物の Coupon を使って組み立てた Order に対してテストを行う方が良策と言えます。

OrderCalculationSpec.groovy
    def "値引き額を取得できる: クーポン適用済"() {
        given: "定額クーポンが適用された注文"
        def sut = OrderHelper.anOrderByOrdinaryCustomer()
        OrderHelper.addOrderLineWithPriceAndQuantity(sut, 1000, 1)
        def coupon = new FixedAmountCoupon(BigDecimal.valueOf(300))
        sut.apply(coupon)

        when:
        def discount = sut.calculateDiscount()

        then:
        discount == BigDecimal.valueOf(300)
    }

※ただ、このケースでは Coupon の実物の代わりにスタブ11偽オブジェクト(Fake Object)12を使っても問題はありません

集約内のエンティティとしてのふるまい

一方、集約ルートの Order の視点から見ると、集約に含まれるそれぞれのオブジェクトはコンポーネントとして捉えることができ、それぞれに期待されるふるまいがあります。
例えば Coupon インタフェースの実装の一つである FixedAmountCoupon (定額クーポン)のふるまいは以下のようなテストコードで検証されるでしょう。

FixedAmountCouponSpec.groovy
    @Unroll
    def "定額で割引される 300円クーポンを #amount 円に適用したときの値引き額は #expected 円"() {
        given: "300円値引きクーポン"
        def sut = new FixedAmountCoupon(BigDecimal.valueOf(300))

        when:
        def discounted = sut.discount(BigDecimal.valueOf(amount))

        then:
        discounted == BigDecimal.valueOf(expected)

        where:
        amount | expected || description
           500 |      300 || "金額 > クーポン値引き額"
           300 |      300 || "金額 = クーポン値引き額"
           299 |      299 || "金額 < クーポン値引き額"
    }

このように、ふるまいの単位は入れ子構造になっている場合が多々あります。

テストケースのオーバーラップについて

さきほどの FixedAmountCoupon に対するテストケースの最後のケース:

        amount | expected || description
           299 |      299 || "金額 < クーポン値引き額"

これは、以下の仕様に対応するケースです。

  • クーポン割引
    • 割引後の金額がマイナスになることはない。

このふるまいは、 Order に対するテストケースでも検証されるかもしれません。

OrderCalculationSpec.groovy
    def "値引き額を取得できる: クーポン適用済2 小計<クーポン額"() {
        given: "定額クーポンが適用された注文"
        def sut = OrderHelper.anOrderByOrdinaryCustomer()
        OrderHelper.addOrderLineWithPriceAndQuantity(sut, 299, 1)
        def coupon = new FixedAmountCoupon(BigDecimal.valueOf(300))
        sut.apply(coupon)

        when:
        def discount = sut.calculateDiscount()

        then: "値引き額は小計額と同額"
        discount == BigDecimal.valueOf(299)
    }

コードの重複は悪という考え方に則り、どちらか一方のテストへ寄せるべきでしょうか?

結論を述べると、テストケースのオーバーラップに関しては余り気にする必要はないと思います。手動のテストでも、単体テストと結合テストで部分的にオーバーラップするでしょう。
ただし、上位のコンポーネントのテストにて、下位のコンポーネントのふるまいの網羅的なテストを行うことは効率が悪いので、避けるべきでしょう。

テストケースクラスの分割について

Order のような集約の場合、提供するふるまいの数が多く、テストケースクラスが肥大化してしまうケースがあります。
テスト対象のクラスひとつに対し、ひとつのテストケースクラスを作らねばならないという思い込みはないでしょうか? 以下の例では、 Order の基本的なふるまいと、金額計算に関わるふるまいでテストケースクラスを分割しています。

テストケースクラス

テストコードはその性質上、プロダクトコードに比べて長く冗長になりがちなので、少しでも管理しやすいように工夫をすることで保守性を維持しましょう。

ユースケース(アプリケーションサービス)

ユースケースのふるまいは、ドメインサービスやドメインオブジェクトなど全ての登場人物の協調によって実現されます。

ユースケース

では、この集合体に対して検証を行えばよいのでしょうか? さすがにユニットテストとしては範囲が広すぎる気がしますね。
逆に、モックを使ってユースケース単体を検証しようとするとどうなるでしょうか? 以下のようなテストコードを記述したとします。

CalculateOrderAmountSpec.groovy
    def "注文金額は明細小計+送料+消費税"() {
        given: "注文"
        def order = OrderHelper.anOrderByOrdinaryCustomer()
        OrderHelper.addOrderLineWithPriceAndQuantity(order, 1500, 1)

        and: "送料"
        def shippingFee = BigDecimal.valueOf(500)

        and: "モックとSUT"
        def shippingCalcServiceMock = Mock(ShippingFeeCalculationService)
        def orderAmountCalcServiceMock = Mock(OrderAmountCalculationService)
        def sut = new CalculateOrderAmountUseCase(shippingCalcServiceMock, orderAmountCalcServiceMock)

        when: "ユースケース実行"
        sut.calculate(order)

        then: "相互作用の検証"
        1 * shippingCalcServiceMock.calculateFee(order) >> shippingFee
        1 * orderAmountCalcServiceMock.calculate(order, shippingFee)
            >> new OrderAmount(BigDecimal.valueOf(1500), shippingFee, BigDecimal.ZERO)

        and: "計算結果の検証"
        def amount = order.getAmount()
        amount.getTotal() == BigDecimal.valueOf(2200)
    }

一見よさそうに見えますが、実はテスト対象のユースケースの実装は以下のようにとてもシンプルなものです。

CalculateOrderAmountUseCase.java
    public void calculate(Order order) {
        BigDecimal shipping = shippingCalcService.calculateFee(order);
        OrderAmount amount = amountCalcService.calculate(order, shipping);
        order.setAmount(amount);
    }

二つのドメインサービスの呼び出しと、 Order オブジェクトの更新を行うだけです。これをモックを使って検証するのは手間ばかり掛かって実利はありません。
このように、ユースケース(アプリケーションサービス)の責務は、複数のオブジェクトを指揮して協調させるオーケストレーションとなり、数行程度のコードとなることが多いです。(このような処理はアプリケーションロジック13と呼ばれることがあります)。

以上のことから、やはりユースケース(アプリケーションサービス)は関係するオブジェクト群を含んだ大きな集合体として、そのふるまいを検証するのが正しいでしょう。
ただし、ユニットテストというよりはより上位のコンポーネント統合テストあるいは受け入れテストという位置づけで検証することが多いと思います。

(補足)アウトサイドインのアプローチ

書籍『実践テスト駆動開発14』で紹介されているアウトサイドインのアプローチを採った場合、先に受け入れテストを記述します。受け入れテストはE2Eテスト15あるいはユースケース(アプリケーションサービス)に対するテストとして記述し、それをパスするために必要な実装をまさにテスト駆動で開発していきます。
その場合、ドメインサービスやドメインオブジェクトの構造や相互作用の設計が発見的・探索的に行われるため、まずはそれらをモックとして仮実装し、利用側のプログラムの実装を進めていきます。

このため、アウトサイドインのアプローチではユースケース(アプリケーションサービス)に対するテストコードでもモックが多用されることになりますが、これは設計の治具としてのテストコードの利用であるため、最終的に残るとは限りません。
アウトサイドインのアプローチに関しては、詳しくは上記書籍を参照してください。また、従来のボトムアップでのテスト駆動開発については書籍『テスト駆動開発16』を、テスト駆動開発の歴史的経緯は和田卓人氏による同書付録Cが参考になります。

まとめ

何を単位としてユニットテストを記述するのかを意識することで、テストコードの見通しがよくなり、より効果的なテストコードを書けるようになるのではないかと筆者は考えています。
実際にはテストコードをリファクタリングしてきれいに保つテクニックの習得も重要になりますが、その辺りは書籍『xUnit Test Patterns: Refactoring Test Code17』を参考にするとよいでしょう。


  1. JSTQBの用語集では コンポーネントテスト:個々のソフトウェアコンポーネントのテスト と書かれており、 コンポーネント:独立してテストできるソフトウェアの最小単位 ともあります。なんか堂々巡りしてる気も。 

  2. David Scott Bernstein 著、オライリー・ジャパン 刊。 

  3. Robert C. Martin 著、アスキードワンゴ 刊。 

  4. エリック・エヴァンス 著、翔泳社 刊。 

  5. 複雑な関連性を持つオブジェクトモデルにおいて不変性を保つべき単位(エンティティと値オブジェクトで構成される)を集約と呼ぶが、その中でルートとなるエンティティのこと。 

  6. テストを実施する際にテスト対象オブジェクトやその依存オブジェクト、環境などが満たすべき状態のこと。事前条件。 

  7. テスト対象を明確化するため、SUTを格納する変数名は sut とするとよい。 

  8. 入出力の差異があるが、セットアップから検証の流れを同じくする複数のテストケースに対して、差異のある入出力をパラメーターとして与えることでテストコードを共通化するテスト設計パターン。 

  9. SUTをテストするために、SUTが依存するリアルなオブジェクトの代替として使用するもの。モック、スタブ、スパイなど様々な種類がある。 

  10. テストダブルの一種で、SUTからの間接出力を観測できるという特徴をもつ。 

  11. テストダブルの一種で、SUTへの間接入力を制御できるという特徴をもつ。 

  12. テストダブルの一種で、SUTが依存するオブジェクトと同じインタフェースを実装したテスト専用の実装。 

  13. 対して、ビジネスルールそのものはドメインロジックと呼ばれる。 

  14. Steve Freeman 著、翔泳社 刊。 

  15. ユーザーインタフェースを操作してシステムを一気通貫で検証するテスト。 

  16. Kent Beck 著、オーム社 刊。 

  17. Gerard Meszaros 著、Addison-Wesley 刊。 

12
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9