Kotlin

Kotlinを使って綺麗なテストを書こう

More than 1 year has passed since last update.

本日ご紹介するKotlinの機能はこちら

Class Delegation

クラスを実装するとき、interfaceの実装を委譲することができます。
これを使うとテストが綺麗に書けますよ、というお話です。

単純なクラス

たとえば、以下のようなクラスがあるとします。

class DataManager {
    /**
     * データ保存先
     */
    private val db = hashMapOf<String, String>()
    fun getData(key: String): String? {
        return db[key]
    }
    fun putData(key: String, value: String) {
        db.put(key, value)
    }
}

データを持ってきたり、保存したりするクラスです。

素朴にテストを書く

このDataManagerのテストを素朴に書くと、こんな感じになると思います。

class DataManagerTest {
    private val dm = DataManager()

    @Test
    fun testGetData() {
        Assert.assertNull(dm.getData("test"))
        dm.putData("test", "1")
        Assert.assertEquals("1", dm.getData("test"))
    }
}

普通のテストですね。
でもこのテストクラス、DataManager専用になっています。
なのでDataManagerを置き換えたくなってDataManager2を作成したときは、そのまま流用できないので大変です。

DataManager2を作成したとき

class DataManager2 {
    /**
     * DataManagerとは異なる方法で保存する。
     * 例:DBを変える、ファイル保存にする、他サーバーへ投げる等
     */
    private val db = hashMapOf<String, String>()
    fun getData(key: String): String? {
        return db[key]
    }
    fun putData(key: String, value: String) {
        db.put(key, value)
    }
}

こんな感じで、DataManager2を作ったとします。(DataManagerとまったく同じですが、保存方法を変えたと脳内変換してください!)
このDataManager2のテストクラスを書くとき、どうします?

DataManagerのテストクラスをコピーする

class DataManagerTest2 {
    private val dm = DataManager2()

    @Test
    fun testGetData() {
        Assert.assertNull(dm.getData("test"))
        dm.putData("test", "1")
        Assert.assertEquals("1", dm.getData("test"))
    }
}

たしかに、こんなふうにテストコードをコピペしてもDataManager2のテストは書けます。
しかし、これだとDataManagerとDataManager2が同じ挙動かどうかのテストはできていません。
DataManagerをDataManager2に置き換えたいなら、DataManager2が単体で正しく動作していることに加えて、同じ挙動になっているかどうかのテストも必須ですよね。

え?テストコードが一致しているから、同じかどうかのテストもできているって?
たしかにその通りですね。いまは。
この変更が一度キリで、DataManager2に置き換えた後DataManagerを削除してしまうなら、これでもいいかもしれません。
・・・でも、もっと綺麗に書きたい!

共通のテストコードを書く

う~む、なんとかしてDataManager2のテストをしつつ、DataManagerとDataManager2が同じ挙動かどうかのテストも同時に行うことはできないでしょうか。
そもそも、なぜDataManagerTestクラスはDataManager専用になってしまっているのでしょうか?

  • テストコードを書いているDataManagerTestクラスがDataManagerにべったり依存しているから

そこで、DataManagerのinterfaceを作成し、それを使ってテストを書くことでDataManagerTestクラスがDataManagerに直接依存するのを回避します。

interfaceを使って、依存性を薄くする

interface DataManagerInterface {
    fun getData(key: String): String?
    fun putData(key: String, value: String)
}

class DataManager : DataManagerInterface {
    /**
     * データ保存先
     */
    private val db = hashMapOf<String, String>()
    override fun getData(key: String): String? {
        return db[key]
    }
    override fun putData(key: String, value: String) {
        db.put(key, value)
    }
}

abstract class DataManagerTest {
    @Test
    fun testGetData() {
        DataManagerInterface dm = getDataManager()
        Assert.assertNull(dm.getData("test"))
        dm.putData("test", "1")
        Assert.assertEquals("1", dm.getData("test"))
    }
    abstract fun getDataManager(): DataManagerInterface
}

class DataManagerTestImpl : DataManagerTest() {
    override fun getDataManager(): DataManagerInterface {
        return DataManager()
    }
}

依存性を薄くするためにinterfaceとテストのabstractクラスが増えてしまいました。
しかし、こうすることでテスト内容を記述したBaseDataManagerTestにはDataManagerへの依存が一切存在しないようになりました。
なのでDataManager2のテストもこう書くだけです。

class DataManagerTestImpl2 : DataManagerTest() {
    override fun getDataManager(): DataManagerInterface {
        return DataManager2()
    }
}

これで、DataManager2のテストをしつつ、DataManagerとDataManager2が同じ挙動かどうかのテストも同時にできるようになりました。

ここまではJavaでも同じ

じつは、ここまではJavaでも同じように書くことができます。
Kotlinのほうが多少記述量は少ないですが、ほとんど似たようなものになります。
しかし、ここからが本番!
Kotlinを使うと、もっと綺麗に書けます。

やっと出てきたClass Delegation

Kotlinにはinterfaceの継承したクラスの実装を委譲させる「Class Delegation」という機能があります。
それを駆使してDataManagerInterfaceの実装を委譲することで、もっとすっきり書くことができます。

テストの抽象クラスにinterfaceを実装させる

まず、DataManagerTestにDataManagerInterfaceを実装させますが、宣言だけに留めます。

abstract class DataManagerTest : DataManagerInterface {
    @Test
    fun testGetData() {
        Assert.assertNull(getData("test"))
        putData("test", "1")
        Assert.assertEquals("1", getData("test"))
    }
}

抽象クラスを継承したテストクラスでClass Delegationを使う

ここでKotlinの委譲機能を使います。

class DataManagerTestImpl : DataManagerTest()
        , DataManagerInterface by DataManager()

これだけ、たったこれだけでOKです。
byというキーワードでinterfaceを実装したインスタンスを渡してやれば、委譲処理を勝手にやってくれます。

まとめ

interface DataManagerInterface {
    fun getData(key: String): String?
    fun putData(key: String, value: String)
}

class DataManager : DataManagerInterface {
    /**
     * データ保存先
     */
    private val db = hashMapOf<String, String>()
    override fun getData(key: String): String? {
        return db[key]
    }
    override fun putData(key: String, value: String) {
        db.put(key, value)
    }
}

abstract class DataManagerTest : DataManagerInterface {
    @Test
    fun testGetData() {
        Assert.assertNull(getData("test"))
        putData("test", "1")
        Assert.assertEquals("1", getData("test"))
    }
}

class DataManagerTestImpl : DataManagerTest()
        , DataManagerInterface by DataManager()

こういう感じでテストを書いておけば、DataManager2を作ったときもそのまま流用することができますね。

class DataManager2 {
    /**
     * DataManagerとは異なる方法で保存する。
     * 例:DBを変える、ファイル保存にする、他サーバーへ投げる等
     */
    private val db = hashMapOf<String, String>()
    fun getData(key: String): String? {
        return db[key]
    }
    fun putData(key: String, value: String) {
        db.put(key, value)
    }
}
class DataManagerTestImpl2 : DataManagerTest()
        , DataManagerInterface by DataManager2()

綺麗に書く手順

綺麗にテストを書く手順は以下の通り

  • テストしたいクラス「A」のpublicメソッドを持つinterface「AI」を定義する。
  • テストしたいクラス「A」でinterface「AI」を実装する。(interfaceのpublicメソッドは実装済みなので、実際は宣言のみでOK)
  • テストクラス「AT」はabstractで書き、interface「AI」の実装は書かないまま、interface「AI」のpublicメソッドを使ってテストを書く
  • abstractになってるテストクラス「AT」を継承した「ATImpl」にClass Delegationを使ってテストしたい対象のクラス「A」を設定する
  • 「AI」を実装した「B」を作ったときは「AT」を継承した「BTImpl」にClass Delegationでクラス「B」を設定する