- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- Other occupations (18)
- Development
- Business
この記事はAdvent Calendar 2021 / Kotlinの13日目の記事になります。
ウォンテッドリーのエンジニアの久保出です。今回は我々が普段開発しているKMMについて発生した課題と、その課題に対してインテグレーションテストを記述することで改善したお話をします。KMMについての説明は過去の記事などをご参照ください。
発生していた課題
InvalidMutabilityException
Kotlin/NativeはCoroutinesと併用すると容易にInvalidMutabilityExceptionなどの例外が起きます。これはKotlin/NativeのメモリマネジメントモデルがKotlin/JVMとは異なり、マルチスレッドセーフティのためにオブジェクトが凍結状態になるためです。詳しい説明はこちらを参照ください。
我々のKMMでは、オブジェクトは具象(class)に依存しないように抽象(interface)を定義して抽象に依存し、テストでは抽象を実装したモックを注入するように設計している反面、テスト時には例外が起こらず実際に実機で実行すると依存先が例外を起こすことがありました。この問題によって、iOSでだけInvalidMutabilityExceptionが発生し、解決するまでKMMの修正からiOS上での実機確認を何度も繰り返し、解決に非常に時間がかかってしまいました。
この問題はユニットテストでは発見しづらいです。なぜなら、凍結状態は対象となるオブジェクトが依存している全てのオブジェクトを凍結するため、依存オブジェクトあるいはそのさらに依存オブジェクトが例外を引き起こすためです。
実際にInvalidMutabilityExceptionを引き起こしていたコード例
class LogoutUseCase(val db: Db) {
suspend operator fun invoke() = db.deleteDatabaseFile()
}
interface Db {
suspend fun deleteDatabaseFile()
}
class DbImpl : Db {
var sqlDriver: SqlDriver
override suspend fun deleteDatabaseFile() {
// ...Deletes SQLite db file
sqlDriver = newSqlDriver()
}
}
fun main() = runBlocking {
// DIは擬似コード
val logout = diObjectGraph.get<LogoutUseCase>()
// スレッド変更によりlogoutが凍結、Dbも凍結される
withContext(Dispatchers.Default) {
// sqlDriver = ...がInvalidMutabilityExceptionを投げる
logout()
}
}
// ユニットテストではMockをDIするので例外が起きなかった
class DbMock : Db {
override suspend fun deleteDatabaseFile() { ... }
}
なお、これはKotlin 1.6から登場した新しいメモリ管理モデルによって発生しなくなるようです。ただしまだExperimentalなので、現状の解決策としては選択しづらいです。
APIスキーマ定義ミス
我々のバックエンドはいくつか世代があり、APIスキーマのないバックエンドも存在します。そのため、実行してみたらAPIスキーマ定義ミスによるJSONパースエラーが発生し、デバッグを繰り返すことがあります。
KMMはこのミスをiOS/Androidの2回でなく1回に減らせる利点がありますが、一方でフルネイティブに比べるとデバッグの時間が長くなってしまう課題があります。なぜかというと、我々はKMMとネイティブアプリをrepositoryから分けていてネイティブにはCocoaPods/Gradleを使ってKMMモジュールを依存させており、KMMのリリースをしないとネイティブでの実機確認ができず、実行するまでわからないようなことを確認するには非常に時間がかかっていました。(このデバッグが長い課題自体は別の解決策を取っていますので今後紹介したい)
これもまたユニットテストでは検知しづらいです。JSONをパースできるかというテストは書けますが、実際にバックエンドが受け付けられるリクエストかどうかのテストはユニットテストでは難しいです。
解決策
これらの課題に対して取った解決策は、どちらもインテグレーションテストを追加するということでした。具体的に書いていきます。
InvalidMutabilityExceptionに対して
テスト時にモックを注入していたため、テスト時には例外が発生しないことが課題の原因の1つでした。そのため、モックしないオブジェクトでテストするインテグレーションテストを追加する事にしました。コードは次のようなシンプルなものです。
class LogoutUseCaseIntegrationTest {
@Test fun test() = runTest {
val logout = actualObjectGraph.get<LogoutUseCase>()
withContext(Dispatchers.Default) {
logout()
}
}
}
このテストは、網羅的に全てのコードに対して書かない限り、InvalidMutabilityExceptionを未然に防ぐことは難しいです。網羅的に書くには非現実的であるため、実際にInvalidMutabilityExceptionが起きた時にInvalidMutabilityExceptionを解消するために記述するテストになります。今回の例のLogoutUseCaseは機能が追加されやすいものであるため、InvalidMutabilityExceptionを解消しても有効に働くテストになるでしょう。
APIスキーマ定義ミスに対して
この課題は、実際に実行してAPIを実行してみないとわからないというのが原因の1つでした。そこで実際にAPIにアクセスするインテグレーションテストを追加しました。
KT-38317にあるように、現状KMMプロジェクト上でのiOSシミュレーターを使ったテストでは外部ネットワークに接続することが難しいです。そのため、Androidでのみ動作するようにテストコードはcommonTestディレクトリではなくandroidTestディレクトリにのみ置いています。(commonTestディレクトリにあるテストコードはiOS/Androidどちらでも実行される、androidTestディレクトリはAndroidでのみ実行される。)書かれたテストコードはiOSではテストしない事になりますが、commonのコードをAndroid上でテストしているため、Androidに依存したコードを書かない限りはiOSでも結果は変わりません。
class ApiIntegrationTest {
@Test fun test() = runTest {
loginWithTestAccount()
val api = actualObjectGraph.get<Api>()
try {
api.getPost(282562)
api.ignore409Conflict { likePost(282562) }
api.ignore409Conflict { unlikePost(282562) }
// ...
} catch (e: IOException) {
// ktorのIOException = 通信エラー
println("Network error")
e.printStackTrace()
}
// HTTPレスポンスエラーやJSONパースエラーはテスト失敗する
}
}
このインテグレーションテストの難点としては、テスト実行に非常に時間がかかることです。30本ほどのAPI実行を直列で行うとそれだけで10秒近くかかってしまいます。なので、テストケース(@Testのfun)自体は1個にしてログインは1度だけ、可能ならAPI実行も並列化すると良いでしょう。
このインテグレーションテストにより、実際にバックエンドが返すレスポンスを使ったテストがKMMだけで行えるようになったため、APIスキーマ定義のデバッグ自体が非常に短く簡単にできるようになりました。
根本的な課題としては、GraphQLのようにAPIスキーマとコード生成により間違えようがない環境が整備されれば良いのですが、バックエンドも簡単には移行できないので現状取れる策としては良い案かなと思っています。一方でGraphQLを使い始めている箇所もあるので、またその話もできたらなと思います。
まとめ
KMMは、書いたコードがiOS/Androidどちらでも等しく動作しますが、現状はメモリマネジメントモデルの違いによりiOSで思ったように動作しないことはあります。また、KMMのコードをアプリで実際に動かすにはどうしても時間がかかるため、APIスキーマ定義などのイテレーションが必要なデバッグには時間がかかってしまいます。
そこで、今回紹介したようなインテグレーションテストを書くことで、実機確認までのリードタイムを減らし、KMMの生産性を上げることができます。
ただし、インテグレーションテストの実行にはコストがかかるため、テスティングピラミッドに従い最小限にとどめましょう。