タクシーアプリ『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/
なぜ移行したのか?
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 SpecとBehavior 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については公式サイトをご確認ください。
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アプリのテストに向いているかはこれから検討していきたいと思います。