Activity以外のクラスで、文字列リソースを取得したいケースがあっていくつかハマりました。
どこでハマったのか
たとえば、こんな感じでViewModelに依存するActivityがあって...
+-----------+ +-----------+
| Activity | | ViewModel |
+-----+-----+ +-----+-----+
| |
| greet() |
+--------------->|
|<---------------+
| Hello |
こんなコードになったとします。
アクティビティ
class MainActivity : AppCompatActivity() {
private val viewModel = MainViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
println("_/_/ ${this.viewModel.greet()}")
}
}
ViewModel
class MainViewModel {
fun greet(): String {
return "こんにちは"
}
}
この場合、当然ViewModelからは"こんにちは"
が返されます。
この "こんにちは"
をリソースから取得しようとするとどうでしょう?
getText()
とかgetString()
を使えば良いんですけど、こいつらContextが無いと呼べないんですよね...
「ViewModelにContext渡せば良いじゃん」と思うかもしれませんが、
それだとActivityが生成されないユニットテストの時に困ります。
ではどうしたかというと...
解決方法
私が思いついた解決方法は、どこからでもリソースを参照できるようにする というものです。
アプリケーションクラス
アプリケーションクラスのインスタンスをcompanion object
として定義した変数に保持しておきます。
class Application : android.app.Application() {
companion object {
lateinit var instance: Application private set // <- これ
}
override fun onCreate() {
super.onCreate()
instance = this // <- これ
}
}
マニフェスト
アプリをアプリケーションクラスから起動するようにします。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myresourceapp">
<application
android:name=".Application"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
リソース
<resources>
<string name="app_name">MyResourceApp</string>
<string name="greeting">こんにちは</string>
</resources>
ViewModel
class MainViewModel {
fun greet(): String {
return Application.instance.getString(R.string.greeting)
}
}
結果
こんな感じでログが表示されました。
ViewModelからリソースの参照に成功しています。
06-25 08:12:08.628 3617-3617/com.example.myresourceapp I/System.out: _/_/ こんにちは
ユニットテスト
ではこのViewModelをテストしてみようと思います。
テストコード
import org.hamcrest.core.Is.*
import org.junit.Assert
import org.junit.Test
class MainViewModelTest {
@Test
fun `greet()メソッドが "こんにちは" を返すこと`() {
val viewModel = MainViewModel()
Assert.assertThat(viewModel.greet(), `is`("こんにちは"))
}
}
結果
テストが失敗して、こんなエラーが表示されました。
kotlin.UninitializedPropertyAccessException: lateinit property instance has not been initialized
これ、アプリケーションクラスがインスタンス化されていないために、
Application
にlateinit
で定義したinstance
が初期化されていない。
と言っているみたいです。
それならばと、app/src/test/
の下にAndroidManifest.xml
をコピーしてみたけど駄目...
じゃあどうしようか...
Robolectric
Robolectricというものを使いました。
これはなにかと言うと、ユニットテストのようなAndroid環境を使わないJVMテストにおいて、
AndroidのAPIを呼び出せるようにしたものらしいです。
ただ、Androidと同じ挙動をするというわけではなく、APIを呼び出してもエラーにならない程度みたいですね。
で、このRobolectricを使うと、Applicationをインスタンス化できます。
Robolectricをプロジェクトに導入
android {
// 省略
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
// 省略
testImplementation "org.robolectric:robolectric:3.8"
}
テストコード
@RunWith
と@Config
を追加します。
@Config
のapplication
に指定したクラスが起動時にインスタンス化されるみたいです。
import org.hamcrest.core.Is.*
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) // <- これ
@Config(application = Application::class) // <- これ
class MainViewModelTest {
@Test
fun `greet()メソッドが "こんにちは" を返すこと`() {
val viewModel = MainViewModel()
Assert.assertThat(viewModel.greet(), `is`("こんにちは"))
}
}
結果
"Process finished with exit code 0"
になって、テストが成功しました!
Downloading: org/robolectric/android-all/8.1.0-robolectric-4611349/android-all-8.1.0-robolectric-4611349.pom from repository sonatype at https://oss.sonatype.org/content/groups/public/
Transferring 2K from sonatype
Downloading: org/robolectric/android-all/8.1.0-robolectric-4611349/android-all-8.1.0-robolectric-4611349.jar from repository sonatype at https://oss.sonatype.org/content/groups/public/
Transferring 95237K from sonatype
Process finished with exit code 0
ただし、初回はRobolectricが100MBくらいのファイルをダウンロードしてるっぽいので時間がかかります。
さらにハマる
これだけで済めばよかったのですが、アプリケーションクラスでAndroidのAPI以外を呼び出すことがあり、
例えばRealmを使っていたるすると...
import io.realm.Realm
class Application : android.app.Application() {
companion object {
lateinit var instance: Application private set
}
override fun onCreate() {
super.onCreate()
Realm.init(this) // <- これ
instance = this
}
}
というような感じでRealmを初期化すると思いますが、
テストを実行するとCan't load library
みたいなことを言われてエラーになります。
実は、これも回避方法があって...
class TestApplication : android.app.Application() {
companion object {
lateinit var instance: TestApplication private set
}
override fun onCreate() {
super.onCreate()
instance = this
}
}
という感じで、Realmを初期化しないアプリケーションクラスを実装して、テストコードの@Config
のとこをこんな風に書き換えます。
@Config(application = TestApplication::class) // <- Application を TestApplication に変更
ここまで修正して、再びテストを実行すると...
kotlin.UninitializedPropertyAccessException: lateinit property instance has not been initialized
エラーになってしまいました。
これはViewModelが
class MainViewModel {
fun greet(): String {
return Application.instance.getString(R.string.greeting)
}
}
こうなっていて、Application
クラスを参照しているからです。
これをTestApplication
に変えたいです。
でもここをTestApplication
に書き換えてしまう訳にもいかないので、テスト時だけ差し替わって欲しいです。
そういう時はどうするかと言うと...
object ResourceFinder {
fun getString(@StringRes id: Int): String {
return Application.instance.resources.getString(id)
}
}
こういうクラスを作って、
class MainViewModel {
fun greet(): String {
return ResourceFinder.getString(R.string.greeting)
}
}
ViewModelから利用します。
そして、こんなクラスも作ります。
TestApplication
経由でリソースを取得します。
@Implements(ResourceFinder::class)
class ShadowResourceFinder {
fun getString(@StringRes id: Int): String {
return TestApplication.instance.resources.getString(id)
}
}
@Implements
というアノテーションで、
「ResourceFinder
の代わりにShadowResourceFinder
を使いますよ」
と宣言しています。
object
じゃなくてclass
にしておかないと駄目です。コンストラクタが見つからないとか言われます。
そして、テストコードの@Config
にshadows
というものを追加します。
配列にして代替クラスを指定します。
@RunWith(RobolectricTestRunner::class)
@Config(application = TestApplication::class, shadows = [ShadowResourceFinder::class]) // <- これ
class MainViewModelTest {
@Test
fun `greet()メソッドが "こんにちは" を返すこと`() {
val viewModel = MainViewModel()
Assert.assertThat(viewModel.greet(), `is`("こんにちは"))
}
}
これでResourceFinder
の代わりにShadowResourceFinder
が使われるようになります。
で、結果はというと...
Process finished with exit code 0
やりました!
ShadowApplication
を作ってshadows
に指定してみたものの、うまくいかなかったのでこうなりました。
これでテストは走るようになったので満足です。
とりあえずは以上です。