MoTLab -GO Inc. Engineering Blog-MoTLab -GO Inc. Engineering Blog-

SpekからKotestへのテストフレームワーク移行

Android
November 29, 2023

タクシーアプリ『GO』のAndroidアプリを開発している祖父江です。 Androidアプリ開発で利用しているテストフレームワークをSpekからKotestへ移行した内容を紹介させていただきます。


Spek、Kotestとは?

ともにKotlinのテストフレームワークで、Androidアプリのユニットテストにも利用可能です。

テストコードを階層的に記載できる、またGiven-When-Thenと振る舞いをテストコードに記載するスタイルをサポートしています。

個人的にはJUnitよりも可読性の高いテストを実装できると思っています。

SpekはSpecification、Gherkinスタイルをサポート

https://www.spekframework.org/specification/

https://www.spekframework.org/gherkin/

Kotestはより多くのスタイルをサポート

https://kotest.io/docs/framework/testing-styles.html

詳細については以下の公式サイトを確認していただければと思います。

https://www.spekframework.org/

https://kotest.io/

なぜ移行したのか?

SpekのテストがAndroid Studio上から実行できない課題がありました。

Spekは開発が停止している状態で、現時点(2023年11月)での最新バージョンリリース日は2022年8月です。Android Studioのプラグインも同様になります。

そのため、最新のAndroid Studioだとプラグインが対応していないためテスト実行ができません。

ターミナルからはテストを実行できるが、Android Studio上ではテストを実行することができない状況です。

この状況は開発時にテスト追加・修正を行った際に、すぐに実行して確認という作業できないため課題になっていました。

比較して、Kotestは開発が活発でAndroid Studio上でのテスト実行も問題なく実行可能です。

Android Studioからテストが実行できるKotestへの移行を決めました。

移行プロセス

共存できるか?

SpekとKotestのテストは共存可能です。

そのため、今回の移行作業は2つのテストフレームワークを使っている状態を一時的に作り、テストクラスごとにKotestへ移行する方法で行いました。

Kotestへの移行が完了したタイミングでSpekの依存関係をすべて削除しました。

Specificationスタイルのテスト移行

KotestにSpekのSpecificationスタイルと同様のスタイルが存在するため移行は簡単です。

KotestのDescribeSpecが対応するスタイルになります。

具体的にはテストクラスの親クラスをSpekからDescribeSpecへ変更してimport文をKotestのクラスをimportするように変更するだけで移行ができます。

-import org.spekframework.spek2.Spek
-import org.spekframework.spek2.style.specification.describe
+import io.kotest.core.spec.style.DescribeSpec

-class SampleEnumTest : Spek({
+class SampleEnumTest : DescribeSpec({
    describe("SampleEnumのテスト") {
        context("enum定数内で重複するrawValueを持たないことを確認する") {
            it("enum定数の数とユニークなrawValue数は同じであるべき") {
                assertThat(SampleEnum.values().size)
                    .isEqualTo(SampleEnum.values().distinctBy { it.rawValue }.size)
            }
        }
    }
})

Gherkinスタイルのテスト移行

Kotestには近いスタイルは存在しますが完全に対応するスタイルは存在しません。

Feature SpecBehavior Specとを合わせたものがGherkinスタイルに近いです。

2つのSpecをincludeでつなげると大きく書き直す必要なく移行ができるかと試してみたのですがテスト実行ができずに諦めました。

そこでSpekのGherkinスタイルの実装済テストは、FreeSpecを用いて移行することにしました。

FreeSpecは名前の通り自由度が高いスタイルになります。自由度が高すぎて新規にテストを実装する際に利用するのは避けたほうが良さそうに感じたのですが、今回はKotestへの既存テストの移行のため利用しました。

FreeSpecは文字列の後ろに-ありなしで意味が異なるスタイルになります。

-ありの場合にはテストケースの説明などを記載して階層を作ることができます。-なしの場合には実際に値を検証する処理を実装するテストスタイルになります。

https://kotest.io/docs/framework/testing-styles.html#free-spec

GherkinスタイルのFeature、Scenario、Given、Whenには-ありでThenには-を付けないようにして移行しました。

SpekのGherkinスタイルテストコード

import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.gherkin.Feature

class SampleViewModelTest : Spek(
    {
        Feature("SampleViewModelTestのテスト") {
            Scenario("正常系のテスト") {
                var viewModel: SampleViewModel? = null
                Given("成功を返すリポジトリで初期化") {
                    viewModel = SampleViewModel(SampleRepository())
                }
                When("executeを実行した場合") {
                    viewModel?.execute()
                }
                Then("resultはtrueであるべき") {
                    assertThat(viewModel?.result).isEqualTo(true)
                }
            }
        }
    },
)

SpekのGherkinスタイルのテストをKotestのFreeSpecを用いて移行したコード

import io.kotest.core.spec.style.FreeSpec

class SampleViewModelTest : FreeSpec(
    {
        "SampleViewModelTestのテスト" - {
            "正常系のテスト" - {
                var viewModel: SampleViewModel? = null
                "成功を返すリポジトリで初期化" - {
                    viewModel = SampleViewModel(SampleRepository())
                    "executeを実行場合" - {
                        viewModel?.execute()
                        "resultはtrueであるべき" {
                            assertThat(viewModel?.result).isEqualTo(true)
                        }
                    }
                }
            }
        }
    },
)

テストスタイル以外での対応

LiveDataのテスト

LiveDataを利用しているクラスのテストを実施する際にはAndroidのMainスレッドがJVMにはないので対応が必要です。

Kotestではextenstionを用いて対応できます。

以下のようなArchTaskExecutorを変更するクラスを用意します。

import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import io.kotest.core.listeners.AfterSpecListener
import io.kotest.core.listeners.BeforeSpecListener
import io.kotest.core.spec.Spec

class LiveDataExtension : BeforeSpecListener, AfterSpecListener {

    override suspend fun beforeSpec(spec: Spec) {
        super.beforeSpec(spec)

        val taskExecutor = object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
            override fun isMainThread(): Boolean = true
            override fun postToMainThread(runnable: Runnable) = runnable.run()
        }
        ArchTaskExecutor.getInstance().setDelegate(taskExecutor)
    }

    override suspend fun afterSpec(spec: Spec) {
        ArchTaskExecutor.getInstance().setDelegate(null)
        super.afterSpec(spec)
    }
}

テストクラスでextensionsに上記のLiveDataExtensionを追加することで、LiveDataのユニットテストが可能になります。

class SampleViewModelTest : FreeSpec({
    extensions(LiveDataExtension())
    ...
})

ViewModel.viewModelScopeを使っているテスト

LiveDataと同様にextensionを用いることでviewModelScopeを利用しているViewModelのテストが可能になります。

以下のようにDispatchers.Mainを変更するクラスを用意します。

import io.kotest.core.listeners.AfterSpecListener
import io.kotest.core.listeners.BeforeSpecListener
import io.kotest.core.spec.Spec
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain

@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineExtension : BeforeSpecListener, AfterSpecListener {

    override suspend fun beforeSpec(spec: Spec) {
        super.beforeSpec(spec)
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    override suspend fun afterSpec(spec: Spec) {
        Dispatchers.resetMain()
        super.afterSpec(spec)
    }
}

テストクラスでextensionsに上記のCoroutineExtensionを追加することで、viewModelScopeのユニットテストが可能になります。

class SampleViewModelTest : FreeSpec({
    extensions(CoroutineExtension())
    ...
})

Spekのmemoizedを利用しているテスト

テストケースごとにプロパティを用意するのは面倒なので Spekではmemoizedを使っていることが多いと思います。

例えばLiveDataのObserverをプロパティに用意してLiveDataに流れてくる値をテストするケースなどです。

Kotestでmemoizedと同様にプロパティを使いまわしたい場合にはIsolationモードの指定を変更することで可能になります。

https://kotest.io/docs/framework/isolation-mode.html

下記のようにisolationModeの設定に変更することでテストケースごとにインスタンスが生成されるので、別テストケースとプロパティを共用していてもインスタンスが別になり別テストケース内でのプロパティへの変更は影響を受けません。

class SampleViewModelTest : FreeSpec({
    isolationMode = IsolationMode.InstancePerLeaf
})

サンプルテストコード

LiveData、viewModelScope利用しているクラスのサンプルテストコードは以下になります。

このサンプルテストコードでのモックにはmockkを利用しています。mockkについては公式サイトをご確認ください。

https://mockk.io/

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.kotest.core.spec.IsolationMode
import io.kotest.core.spec.style.FreeSpec
import io.mockk.coEvery
import io.mockk.confirmVerified
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import kotlinx.coroutines.launch

class SampleViewModelTest : FreeSpec(
    {
        extensions(LiveDataExtension(), CoroutineExtension())

        isolationMode = IsolationMode.InstancePerTest

        "SampleViewModelTestのテスト" - {
            val observer: Observer<Boolean> = spyk()
            val repository: SampleRepository = mockk()
            val viewModel = SampleViewModel(repository)
            "Repositoryがtrueを返す場合" - {
                coEvery { repository.fetch() } returns true
                "resultを監視していること" - {
                    viewModel.result.observeForever(observer)
                    "Repositoryから情報を取得する" - {
                        viewModel.fetch()
                        "resultにはtrueが通知されるべき" {
                            verify { observer.onChanged(true) }
                            confirmVerified(observer)
                        }
                    }
                }
            }
            "Repositoryがfalseを返す場合" - {
                coEvery { repository.fetch() } returns false
                "resultを監視していること" - {
                    viewModel.result.observeForever(observer)
                    "Repositoryから情報を取得する" - {
                        viewModel.fetch()
                        "resultにはfalseが通知されるべき" {
                            verify { observer.onChanged(false) }
                            confirmVerified(observer)
                        }
                    }
                }
            }
        }
    },
)

class SampleViewModel(
    private val repository: SampleRepository,
) : ViewModel() {

    val result: MutableLiveData<Boolean> = MutableLiveData()

    fun fetch() {
        viewModelScope.launch {
            result.value = repository.fetch()
        }
    }
}

interface SampleRepository {

    suspend fun fetch(): Boolean
}

まとめ

Kotestへ移行により快適にAndroid Studio上でテストを実行できる環境になりました。

SpekからKotestへの移行はSpecificationスタイルの移行はすごく簡単に移行できる。Gerkinスタイルの移行はそのまま移行できるスタイルが存在しないため、少し手間がかかりました。

Kotestへの移行が完了した時点では、DescribeSpecとFreeSpecとの2つのスタイルしか使っていないですが他にもスタイルが多く、どのスタイルがAndroidアプリのテストに向いているかはこれから検討していきたいと思います。


We're Hiring!

📢
GO株式会社ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @goinc_techtalk のフォローもよろしくお願いします!