タクシーアプリ『GO』のAndroidアプリを開発している祖父江です。 Androidアプリ開発で利用しているテストフレームワークをSpekからKotestへ移行した内容を紹介させていただきます。
ともにKotlinのテストフレームワークで、Androidアプリのユニットテストにも利用可能です。
テストコードを階層的に記載できる、またGiven-When-Thenと振る舞いをテストコードに記載するスタイルをサポートしています。
個人的にはJUnitよりも可読性の高いテストを実装できると思っています。
https://www.spekframework.org/specification/
https://www.spekframework.org/gherkin/
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の依存関係をすべて削除しました。
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)
}
}
}
})
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を利用しているクラスのテストを実施する際には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())
...
})
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を使っていることが多いと思います。
例えば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アプリのテストに向いているかはこれから検討していきたいと思います。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @goinc_techtalk のフォローもよろしくお願いします!