18
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Activity以外のクラスでリソースを取得する

Last updated at Posted at 2018-06-25

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  // <- これ
    }
}

マニフェスト

アプリをアプリケーションクラスから起動するようにします。

AndroidManifest.xml
<?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>

リソース

string.xml
<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からリソースの参照に成功しています。

logcat
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

これ、アプリケーションクラスがインスタンス化されていないために、
Applicationlateinitで定義したinstanceが初期化されていない。
と言っているみたいです。

それならばと、app/src/test/の下にAndroidManifest.xmlをコピーしてみたけど駄目...

じゃあどうしようか...

Robolectric

Robolectricというものを使いました。

これはなにかと言うと、ユニットテストのようなAndroid環境を使わないJVMテストにおいて、
AndroidのAPIを呼び出せるようにしたものらしいです。
ただ、Androidと同じ挙動をするというわけではなく、APIを呼び出してもエラーにならない程度みたいですね。

で、このRobolectricを使うと、Applicationをインスタンス化できます。

Robolectricをプロジェクトに導入

app/build.gradle
android {

    // 省略

    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}

dependencies {

    // 省略

    testImplementation "org.robolectric:robolectric:3.8"
}

テストコード

@RunWith@Configを追加します。
@Configapplicationに指定したクラスが起動時にインスタンス化されるみたいです。

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にしておかないと駄目です。コンストラクタが見つからないとか言われます。

そして、テストコードの@Configshadowsというものを追加します。
配列にして代替クラスを指定します。

@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に指定してみたものの、うまくいかなかったのでこうなりました。

これでテストは走るようになったので満足です。

とりあえずは以上です。

18
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?