97
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

StudyCoAdvent Calendar 2020

Day 20

これで迷わないテストダブルの分類(ダミー、スタブ、スパイ、モック、フェイク)

Last updated at Posted at 2020-12-20

はじめに

みなさんユニットテスト書いてますか?

私はユニットテストを書くとき、テストダブルを利用することがありますが、
スタブ、スパイなど、しっかり理解せずに利用しており、そろそろ理解しないとまずそうなので、ネットの海をさまよっていたところ、t_wadaさんのこんなツイートを見つけました。

このツイートで紹介されていたブログでは、テストダブルの分類についてわかりやすく書かれていました。
今回はこの記事の内容を前提にして、大元である xUnit Test Patternsのサンプルを用いて(少しアレンジしてます)、テストダブルについてまとめてみました。
(ただし、モックとフェイクについてのサンプルは書いていません。いいかんじに例をアレンジできませんでした。)

ユニットテストでなぜテストダブルが必要になるのかをみつつ、それぞれの分類を見ていきます。

ユニットテスト

まずユニットテストですが、ソースコードの個々のユニット単位(メソッド、クラス、モジュールなど)のプログラムが正しい動きをするか検証する方法です。
以下は、自動テストの文脈でユニットテストを見ていきます。

簡単な例

簡単な例ですが、以下のようなプロダクトコードがあるとします。
(以下すべてKotlinのサンプルコードですが、他の言語でも読み変えられるはず。)

Calculatorクラスはsqureという与えられた数の平方を返すメソッドを持っています。

class Calculator {
    fun square(a: Int): Int = a * a
}

このメソッドのテストを書くとしましょう。
テストを書くにはまず、テストケースを考えます。
「2を与えると4を返す」ので、テストコードは以下になります。

class CalculatorTest {

    @Test
    fun `square_2の平方は4になる`() {
        // Given
        val calculator = Calculator()

        // When
        val actual = calculator.square(2)

        // Then
        assertEquals(4, actual)
    }
}

テストダブルが欲しくなる例

次に、この例はどうでしょうか。

TimeDisplayという時間に関係する表示を行うクラスで、getCurrentTimeAsHtmlFragmentメソッドを持っています。
そのメソッド内で、TimeProviderから取得した時間によって出力するHTMLタグの部品の内容が Midnight, noon などに変わります。

class TimeDisplay(private val timeProvider: TimeProvider) {
    fun getCurrentTimeAsHtmlFragment(): String {
        val now = timeProvider.getTime()

        val text = when {
            now.hour == 0 && now.minute == 0 -> {
                "Midnight"
            }
            now.hour == 12 && now.minute == 0 -> {
                "noon"
            }
            else -> {
                val f = DateTimeFormatter.ofPattern("yyyy年MM月dd日")
                now.format(f)
            }
        }
        return HTML_TEMPLATE.format(text)
    }

    companion object {
        private const val HTML_TEMPLATE = "<span class=\"tinyBoldText\">%s</span>"
    }
}

class TimeProvider {
    fun getTime(): LocalDateTime = LocalDateTime.now()
}

このメソッドのテストケースを書こうとしてみます。
「0:00のときMidnightを含むHTMLタグの部品を出力する」というテストケースを考えてみます。

class TimeDisplayTest {

    @Test
    @Ignore
    fun `testDisplayCurrentTime_0:00のときMidnightを含むHTMLタグの部品を出力する`() {
        // Given
        val timeProvider = TimeProvider()
        val sut = TimeDisplay(timeProvider)

        // When
        // TimeProviderがシステムの時間を返すため、実行時に値が変わる
        val result = sut.getCurrentTimeAsHtmlFragment()

        // Then
        val expectedTimeString = "<span class=\"tinyBoldText\">Midnight</span>"
        assertEquals(expectedTimeString, result)
    }
}

このテストケースは大抵うまくいきません。
システムの時間によってTimeProviderが返す時刻が違うため、実行する時間によってテストケースの結果が変わってしまいます
Midnightであることを確かめるには、0:00にテストコードを実行するしかありませんが、あまり良いとは言えません。

テストの記述がうまくいかない理由は、このテスト対象であるメソッドが、外部コンポーネント(TimeProviderクラス)に依存しているためです。
この問題を回避する方法はいくつかありますが、テストダブルを利用することを考えます。

テストダブル

テストダブルとは、テスト実行時に、テスト対象が依存しているコンポーネントと置き換わるものです。

|テストコード| => |テスト対象| => |外部コンポーネント|

これを

|テストコード| => |テスト対象| => |テストダブル|

にします。

テストダブルの由来、かどうかはわかりませんが、xUnit Test PatternsのTest Doubleの説明には以下の記述があり、映画のスタントマン(stunt double)の例えが用いられています。

When the movie industry wants to film something that is potentially risky or dangerous for the leading actor to carry out, they hire a "stunt double" to take the place of the actor in the scene. The stunt double is a highly trained individual who is capable of meeting the specific requirements of the scene. They may not be able to act, but they know how to fall from great heights, crash a car, or whatever the scene calls for. How closely the stunt double needs to resemble the actor depends on the nature of the scene. Usually, things can be arranged such that someone who vaguely resembles the actor in stature can take their place.

なぜ置き換えたいのか

上記のTimeDisplayのように、外部コンポーネントに依存していて、その挙動をコントロールできないときや、実際のDBを操作したり、実行に時間がかかる処理など、コスト的・時間的・環境的にテストで実行できない or しにくいものなど、実行上の制約があるものを、テストダブルで置き換えます。

テストダブルの分類

ネットを調べると、テストダブルの定義や分類は、いろいろあるようですが、ここではxUnit Test Patternsの定義、分類を見ていきます。

xUnit Test Patternsとは、テスト自動化フレームワークのXUnit(JUnitなど)を使用して、自動テストを作成するためのパターン、もしくはその書籍のことです。

xUnit Test Patternsでの分類は以下の5つです。

  • ダミー(Dummy Object)
  • スタブ(Test Stub)
  • スパイ(Test Spy)
  • モック(Mock Object)
  • フェイク(Fake Object)

間接入力(indirect input)と間接出力(indirect output)

テストダブルの5つそれぞれが間接入力(indirect input)、間接出力(indirect output)を理解しているとわかりやすいため、紹介されていたブログを見てください。

モックライブラリ

テストダブルをどう作るかですが、テストダブルに置き換えたいクラスをインターフェースや抽象クラスに変更して置き換える方法などがあります。
しかし、テストダブルを作るために大元のプロダクトコードを修正したり、テストごとに都度用意するのは面倒なので、大抵はモックライブラリを利用します。
以下では MockK というKotlinのモックライブラリを利用します。(他の言語でも利用方法は似ていたりします。)

Dummy Object

Dummy Objectは、テスト対象内でなにもしませんが、コンパイルなどの都合上、用意する必要があるものです。

例を見ます。
以下のようなInvoice(請求書)クラスがあり、Product(製品)とそのquantity(数量)をLineItem(行)としてlineItems リストで保持しています。
addItemQuantityメソッドでLineItemを保存して、getLineItemsメソッドで今保持しているLineItemのリストを返します。

class Invoice(customer: Customer) {
    private var lineItems: MutableList<LineItem> = mutableListOf()

    fun addItemQuantity(product: Product, quantity: Int) {
        lineItems.add(LineItem(this, product, quantity))
    }

    fun getLineItems(): List<LineItem> {
        return lineItems.toList()
    }
    
    // ... customerを利用するコードなど
}

class Customer(uniqueNumberAsString: String, uniqueNumber: Int, address: Address)
class Address(address: String, city: City, postalCode: String)
class City(name: String, state: State)
class State(name: String, abbreviation: String)

LineItemを1つ追加して、保持しているLineItemの数と内容が同じかを検証するテストケースを考えます。

class InvoiceTest {

    @Test
    fun `LineItem1つ追加して、保持しているLineItemの数と内容が同じ`() {
        // Given
        val QUANTITY = 1
        val product = Product(getUniqueNumberAsString(), getUniqueNumber())

        // Customerを作るのに必要
        val state = State("West Dakota", "WD")
        val city = City("Centreville", state)
        val address = Address("123 Blake St.", city, "12345")
        val customer = Customer(getUniqueNumberAsString(), getUniqueNumber(), address)

        // Invoiceイスタンスを作るのにCustomerが必要だが、テスト対象にCustomerは不要
        val inv = Invoice(customer)

        // When
        inv.addItemQuantity(product, QUANTITY)

        // Then
        val lineItems: List<LineItem> = inv.getLineItems()
        assertEquals("LineItemの数が同じか", lineItems.size, 1)

        val actual: LineItem = lineItems[0]
        val expItem = LineItem(inv, product, QUANTITY)
        assertEquals("LineItemが同じか", expItem, actual)
    }
}

private fun getUniqueNumberAsString() = "1"
private fun getUniqueNumber() = 1

このテストコードは通ります。
しかし、Invoiceのインスタンスを作るのにCustomerが必要になっていますが、このテスト対象のメソッド内ではCustomerを利用しておらず、不要になっています。
他にもCustomer, Address, City, Stateというクラスもあり、これはすべてCustomerインスタンスを作るためにテストコード内で実体を作っていますが、どれもテスト対象には不要なものです。

これをダミーオブジェクトに切り変えます。

class InvoiceTest {

    @Test
    fun `LineItem1つ追加して、保持しているLineItemの数と内容が同じ`() {
        // Given
        val QUANTITY = 1
        val product = Product(getUniqueNumberAsString(), getUniqueNumber())

        // Customerをモックライブラリ(mockK)でダミーに切り替える
        val customerDummy: Customer = mockk()
        val inv = Invoice(customerDummy)

        // When
        inv.addItemQuantity(product, QUANTITY)

        // Then
        val lineItems: List<LineItem> = inv.getLineItems()
        assertEquals("LineItemの数が同じか", lineItems.size, 1)

        val actual: LineItem = lineItems[0]
        val expItem = LineItem(inv, product, QUANTITY)
        assertEquals("LineItemが同じか", expItem, actual)
    }
}

mockk()Customerのダミー(テストダブル)を作成しました。
不要なインスタンス化部分のコードをなくすことができました。

Test Stub

Test Stubとは、間接入力を操作し、任意の値をテスト対象に与えるものです。
テストコード上で、外部コンポーネントからの入力を事前設定し、テストを実行します。

Test Stubの例は、先出のTimeDisplayで見ていきます。
冗長になってしまいますが、再掲します。

class TimeDisplay(private val timeProvider: TimeProvider) {
    fun getCurrentTimeAsHtmlFragment(): String {
        val now = timeProvider.getTime()

        val text = when {
            now.hour == 0 && now.minute == 0 -> {
                "Midnight"
            }
            now.hour == 12 && now.minute == 0 -> {
                "noon"
            }
            else -> {
                val f = DateTimeFormatter.ofPattern("yyyy年MM月dd日")
                now.format(f)
            }
        }
        return HTML_TEMPLATE.format(text)
    }

    companion object {
        private const val HTML_TEMPLATE = "<span class=\"tinyBoldText\">%s</span>"
    }
}

class TimeProvider {
    fun getTime(): LocalDateTime = LocalDateTime.now()
}

このメソッドのテストケースを書こうとしてみます。
「0:00のときMidnightを含むHTMLタグの部品を出力する」というテストケースを考えてみます。

class TimeDisplayTest {

    @Test
    @Ignore
    fun `testDisplayCurrentTime_0:00のときMidnightを含むHTMLタグの部品を出力する`() {
        // Given
        val timeProvider = TimeProvider()
        val sut = TimeDisplay(timeProvider)

        // When
        // TimeProviderがシステムの時間を返すため、実行時に値が変わる
        val result = sut.getCurrentTimeAsHtmlFragment()

        // Then
        val expectedTimeString = "<span class=\"tinyBoldText\">Midnight</span>"
        assertEquals(expectedTimeString, result)
    }
}

このテストケースでは、TimeProviderがシステムの時間を返すため、実行時に値が変わってしまい、テストがうまくいきませんでした。
このテストケースは、TimeProvider が返す特定の時間によって、HTMLタグの内容が変わるロジックを検証することが目的なので、TimeProviderが返す値(間接入力)を操作するために、Test StubでTimeProviderを置き換えます。

class TimeDisplayTest {

    @Test
    @Ignore
    fun `testDisplayCurrentTime_0:00のときMidnightを含むHTMLタグの部品を出力する`() {
        // Given
        // timeProvider.getTime()からの入力をStubに置き換える
        val timeProviderStub: TimeProvider = mockk()
        every { timeProviderStub.getTime() } returns LocalDateTime.of(
                2020,
                1,
                1,
                0,
                0)
        val sut = TimeDisplay(timeProvider)

        // When
        // TimeProviderがシステムの時間を返すため、実行時に値が変わる
        val result = sut.getCurrentTimeAsHtmlFragment()

        // Then
        val expectedTimeString = "<span class=\"tinyBoldText\">Midnight</span>"
        assertEquals(expectedTimeString, result)
    }
}

every { ... } returns ~ の部分は、mockKの書き方ですが、 timeProvider.getTime() が実行された時に、間接入力として、常に LocalDateTime.of(2020, 1, 1, 0, 0) を返すようにしています。

Test Stubに置き換えたことによって、外部コンポーネントからの入力をコントロールでき、本来確かめたいロジックのテストコードが書けるようになりました。

Test Spy

Test Spyは、テスト対象の間接出力を記録します。
テスト対象で呼び出している外部モジュールの出力を記録して、テストコード上で、その間接出力を検証します。

フライトの予約窓口の例を考えます。
FlightManagementFacadeクラスは、reserveFlightでフライトを予約し、cancelFlightでフライトをキャンセル、confirmFlightでフライトが予約済みか確認するメソッドを持っています。

FlightManagementFacade は、FlightDaoの外部コンポーネント(クラス)を持っており、 FlightDao を通して、フライト業務をこなします。

ここで、簡略化のため、FlightDao自身で予約済みのフライト番号(flightNumbers)を保持するようにしていますが、実際はDBを操作するコードが書かれていたりします。

class FlightManagementFacade(
        private val dao: FlightDao
) {

    fun reserveFlight(flightNumber: Int) {
        // 外部メソッド
        dao.saveFlight(flightNumber)
    }

    fun cancelFlight(flightNumber: Int) {
        // 外部メソッド
        dao.removeFlight(flightNumber)
    }

    fun confirmFlight(flightNumber: Int): Boolean {
        return dao.hasFlight(flightNumber)
    }
}

class FlightDao {
    private val flightNumbers: MutableSet<Int> = mutableSetOf()

    fun removeFlight(flightNumber: Int) {
        flightNumbers.remove(flightNumber)
    }

    fun saveFlight(flightNumber: Int) {
        flightNumbers.add(flightNumber)
    }

    fun hasFlight(flightNumber: Int): Boolean {
        return flightNumbers.contains(flightNumber)
    }
}

FlightManagementFacadereserveFlightcancelFlightメソッドを何度か呼び出して、ちゃんと予約したフライトを保持しているか検証するテストケースを考えていみます。

class FlightManagementFacadeTest {

    @Test
    fun testRemoveFlight_2つ別のフライトを予約して、1つをキャンセルしたときに、1つはキャンセル、1つは予約済みにされているか() {
        // Given
        val flightNumber1 = 1
        val flightNumber2 = 2

        val flightDao = FlightDao()
        // テスト対象
        val facade = FlightManagementFacade(flightDao)

        // When
        facade.reserveFlight(flightNumber1)
        facade.reserveFlight(flightNumber2)
        facade.cancelFlight(flightNumber1)

        // Then
        // flightDaoの間接出力を検証できていない
        assertFalse("flight1 should not exist after being removed",
                facade.confirmFlight(flightNumber1)
        )
        assertTrue(facade.confirmFlight(flightNumber2) , "flight2 should exist")
    }
}

このテストはうまく通りますが、よくよくみると、本当にFlightDaoのメソッドを呼んでいるのか、意図したflightNumberを渡しているのかどうかは検証できていません。
もしかしたら、FlightDaoがflightNumber2を元から保持していたかもしれませんし、flightNumber1自体をreserveFlightでもcancelFlightでも実際にはFlightDaoに渡しておらず、操作していないかもしれません。
FlightDao がちゃんと値を保持しているかどうかはFlightDao自体のテストかと思いますが、FlightManagementFacade が、reserveFlightcancelFlightメソッド内で、FlightDaoのメソッドを呼んでいるかどうか(外部コンポーネントを呼ぶ責務を果たしているかどうか)を検証するためには、Test Spyを利用するように修正します。

※自分で少し例をアレンジしていますが、あまり良い例になっていないかもしれません。。テストケース次第なので、あくまでTest Spyの例だと思ってください。

class FlightManagementFacadeTest {

    @Test
    fun `testRemoveFlight_FlightManagementFacadeFlightDAOのメソッドを呼び出しているか`() {
        // Given
        val flightNumber1 = 1
        val flightNumber2 = 2

        // TestSpyにする
        val flightDaoSpy = spyk<FlightDao>()

        // テスト対象
        val facade = FlightManagementFacade(flightDaoSpy)

        // When
        facade.reserveFlight(flightNumber1)
        facade.reserveFlight(flightNumber2)
        facade.cancelFlight(flightNumber1)

        // Then
        // FlightDaoのメソッド呼び出しと渡した引数(間接出力値)を検証
        verify(exactly = 1) { flightDao.saveFlight(flightNumber1) }
        verify(exactly = 1) { flightDao.saveFlight(flightNumber2) }
        verify(exactly = 1) { flightDao.removeFlight(flightNumber1) }
        verify(exactly = 0) { flightDao.removeFlight(flightNumber2) }
    }
}

val flightDaoSpy = spyk<FlightDao>()FlightDaoをTest Spyにして、メソッドの呼び出しやその渡された引数を記録させます。
verify(exactly = 1) { flightDao.saveFlight(flightNumber1) } でメソッドの呼び出し回数と、渡された引数を検証しています。

Mock Object

Mock Objectは、テスト対象の間接出力の期待結果(間接出力がこうなって欲しいというものを渡す)と、間接出力を保持します。
間接出力を確保できたら、Mock Object内で間接出力と、間接出力の期待結果を比較検証して、成功か失敗かを判定します。

Test Spyとかなり似ていますが、Mock Objectは、Mock Object内で間接出力の結果を評価します。
Test Spyは、間接出力を保持するだけで、その評価はテストコード側で行います。

どう使い分けるかですが、テストダブル内でしかアクセスできない情報がある場合は、Test Spyではなく、Mock Objectを使います。

Fake Object

Fake Objectはテスト実行中、本物と同じように動くが、本物ではないものです。
例としては、SQLiteに対し、同じ機能を持ったオンメモリデータベースなどが該当します。

その他注意

テストダブル を利用する上で、注意すべきことがあります。

「モックする」という言葉

テストダブルを作る行為を単に モックする と言うことがあります。この場合、Mock Objectだけでなく、Test Stubなど他のテストダブルに対しても、「モックする」と言います。

「スタブのテスト」のような意味のないユニットテスト

「スタブのテスト」のような意味のないユニットテストを書いてしまうことがあるので注意です。
自分で外部コンポーネントの入力を操作したのに、その外部コンポーネントの入力値自体を検証するコードは意味がありません。
その場合、テスト対象で検証したいのは、外部コンポーネントの値によって挙動が決まるロジックのはずです。

テストダブルが多くなってしまう場合はそもそもの設計を疑え

テストコードで、テストダブルを多く利用するということは、それだけ依存するものが多いと言うことなので、そもそものプロダクトコードの設計が良くない場合があります。

ただし、「リファクタリングのために、プロダクトコードをいきなり修正せず、まずテストコードを入れる時」など、仕方のない場面もあります。

モックライブラリによっては、どのテストダブルを生成するか特に明示しない

上記MockKを利用した例で、Dummy Object, Test Stub共に、 mockk() でテストダブルを作っただけで、「dummy, stubとして生成する」といった指定はしませんでした。
利用する側で上記のテストダブルの分類(何が目的か)を意識すべきですが、モックライブラリでは分類を意識したテストダブルの生成ではないようです。(他のモックライブラリもそうなっているのかはわかりません。)

まとめ

上で見てきたことをまとめると以下になります。

  • xUnit Test Patternsによるテストダブルの分類がある
  • スタブは間接入力を操作する
  • スパイは間接出力を記録する
  • ダミーは何もしない
  • モックは間接出力の期待結果を持ち、中で評価する
  • フェイクは限りなく本物に近いけど違うもの
  • モックライブラリを使いましょう

テストダブルはとても便利ですが、意識して使わないとテストコードを無駄に複雑にしてしまうことになるので、それぞれのテストダブルが何を目的にしているのか十分理解して利用するようにしましょう。

参考

97
66
1

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
97
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?