【Android】ウィジェット（Widget）のUIテストを書く

#はじめに
エンジニアであればUNITテストやUIテストを書いていると思います。
ActivityやFragmentは参考になるespresso等のテスト記事を多く見かけます。
しかし、ウィジェット（[RemoteViews](https://developer.android.com/reference/android/widget/RemoteViews)）のテスト方法がネット上を検索してもあまりなかったのでメモがてら残しておきます。

#仕様
まず、ウィジェットの仕様を考えます。
テストがメインなのでシンプルな仕様にします。
・Viewはボタンとテキスト
・ボタンを押すと1づつカウントアップ
・テキストの初期表示は0
・最大は12で最大を超えると0に戻る
・3の倍数で文字色が赤になる

#ウィジェットの実装
まずはウィジェットを実装して動かしてみましょう。

ウィジェットなのでAppWidgetProviderクラスが必要です。

```kotlin:TestAppWidgetProvider.kt
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent

class TestAppWidgetProvider : AppWidgetProvider() {

    private lateinit var testRemoteViews: TestRemoteViews

    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        for (appWidgetId in appWidgetIds) {

            // SharedPreferencesから保存しているカウントを読み出す
            val clickCount = getClickCount(context)

            // ウィジェットを更新
            getTestRemoteViews(context).updateAppWidget(
                appWidgetManager,
                appWidgetId,
                clickCount.toString(),
                isSpecialCount(clickCount)
            )
        }
    }

    override fun onReceive(context: Context?, intent: Intent?) {
        super.onReceive(context, intent)

        if (context !is Context || intent !is Intent) return

        when (intent.action) {
            COUNT_UP -> {
                //　1足した値を取得
                val clickCount = getAddCount(context)
                putClickCount(context, clickCount)

                // TextViewにカウント値を適用
                val views = getTestRemoteViews(context)
                views.setCounterTextViewText(clickCount.toString(), isSpecialCount(clickCount))

                // ウィジェットを更新
                val myWidget = ComponentName(context, TestAppWidgetProvider::class.java)
                val manager = AppWidgetManager.getInstance(context)
                manager.updateAppWidget(myWidget, views)
            }
        }
    }

    private fun getTestRemoteViews(context: Context): TestRemoteViews {
        if (!::testRemoteViews.isInitialized) testRemoteViews = TestRemoteViews(context)
        return testRemoteViews
    }

    /**
     * カウントアップ後のカウント値を読み込む.
     */
    private fun getClickCount(context: Context): Int {
        val dataStore = context.getSharedPreferences("test_widget", Context.MODE_PRIVATE)
        return dataStore.getInt("click_count", DEFAULT_COUNT)
    }

    /**
     * カウントアップ後のカウント値を書き込む.
     */
    private fun putClickCount(context: Context, clickCount: Int) {
        val dataStore = context.getSharedPreferences("test_widget", Context.MODE_PRIVATE)
        dataStore.edit().putInt("click_count", clickCount).apply()
    }

    private fun getAddCount(context: Context): Int {
        var clickCount = getClickCount(context)
        clickCount++
        return if (clickCount > MAX_COUNT) {
            DEFAULT_COUNT
        } else {
            clickCount
        }
    }

    /**
     * 3の倍数か.
     */
    private fun isSpecialCount(clickCount: Int): Boolean {
        if (clickCount <= 0) return false
        return (clickCount % 3 == 0)
    }

    companion object {
        const val COUNT_UP = "intent.TestAppWidgetProvider.CountUp"
        const val DEFAULT_COUNT = 0
        const val MAX_COUNT = 12
    }
}
```

RemoteViewsで表示を更新する実装にしました。

```kotlin:TestRemoteViews.kt
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import androidx.core.content.ContextCompat

class TestRemoteViews(val context: Context) :
    RemoteViews(context.packageName, R.layout.test_app_widget) {

    fun updateAppWidget(
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int,
        clickCount: String,
        isSpecialColor: Boolean
    ) {
        //TextViewにカウント値を適用
        setCounterTextViewText(clickCount, isSpecialColor)

        //Button押下通知用のPendingIntentを作成しに登録
        val countIntent = Intent(context, TestAppWidgetProvider::class.java).apply {
            action = TestAppWidgetProvider.COUNT_UP
        }
        val countPendingIntent =
            PendingIntent.getBroadcast(context, 0, countIntent, PendingIntent.FLAG_UPDATE_CURRENT)
        setOnClickPendingIntent(R.id.add_button, countPendingIntent)

        // ウィジェットを更新
        appWidgetManager.updateAppWidget(appWidgetId, this)
    }

    fun setCounterTextViewText(clickCount: String, isSpecialColor: Boolean) {
        setTextViewText(R.id.counter_text, clickCount)
        if (isSpecialColor) {
            setTextColor(
                R.id.counter_text,
                ContextCompat.getColor(context, android.R.color.holo_red_light)
            )
        } else {
            setTextColor(R.id.counter_text, ContextCompat.getColor(context, android.R.color.black))
        }
    }
}
```

レイアウトも作っておきます。
previewImageは今回は仮なのでアプリアイコンをセットします。

```xml:test_app_widget.xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/test_app_widget"
    android:minWidth="40dp"
    android:minHeight="40dp"
    android:previewImage="@mipmap/ic_launcher"
    android:resizeMode="horizontal|vertical"
    android:updatePeriodMillis="86400000" />
```

```xml:test_app_widget.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/holo_green_light"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="@dimen/widget_margin">

    <TextView
        android:id="@+id/counter_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="0"
        android:textColor="@android:color/black"
        android:textStyle="bold" />

    <Button
        android:id="@+id/add_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="+" />

</LinearLayout>
```

AndroidManifestにも忘れずに記載します。

```xml:AndroidManifest.xml
<receiver android:name="TestAppWidgetProvider" >
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>
    <meta-data android:name="android.appwidget.provider"
                android:resource="@xml/test_appwidget_info" />
</receiver>
```

#ウィジェットの動作確認
ビルドしてエミュレータにインストールします。
↓のようなウィジェットができました。

![スクリーンショット 2021-01-04 18.38.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264726/44139a81-4a48-112f-c320-038ba29f7315.png)

ボタンをクリックするとカウントアップして3の倍数で文字が赤くなります。
![スクリーンショット 2021-01-04 18.39.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264726/ffa6f355-8bd2-f0b4-96a4-80aa3c60c185.png)

#UIテストの準備
ここからが本番です。

テストの為にgradleに定義を追加します。

```Gradle:build.gradle
android {

    // ↓を忘れずに追加
    testOptions {
        unitTests {
            includeAndroidResources = true
        }
    }
}

dependencies {
    // ↓を追加(最新のバージョンにしてください)
    testImplementation 'junit:junit:4.13'
    testImplementation 'androidx.test:core:1.3.0'
    testImplementation 'org.robolectric:robolectric:4.3.1'
}
```
テスト用のApplicationクラスを作ります。
クラスの作成場所はtestパッケージ配下です。
![testApp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/264726/5f01e9b4-f08c-e7f9-086f-41f9d5439920.png)


```kotlin:TestApplication.kt
import android.app.Application

class TestApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        // ここにテスト用に必要な処理が書ける

        // FirebaseApp.initializeApp(this)
        // といった必要な初期処理が書けるので便利
    }
}
```

robolectric.propertiesというファイルを作ります。
ファイルを app/src/test/resources ディレクトリに置おきます。

```properties:robolectric.properties
sdk=28
application=com.example.widgetapplication.TestApplication
```

#UIテストを書く
準備ができたのでテストクラスを作成しテストを書きます。

```kotlin:TestRemoteViewsTest.kt
import android.content.Context
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class TestRemoteViewsTest {

    private lateinit var context: Context

    @Before
    fun init() {
        // これでContextが取得できる
        context = ApplicationProvider.getApplicationContext()
    }

    @Test
    fun 初期値のテスト() {
        // この方法でRemoteViewが取得できる
        val testRemoteViews = TestRemoteViews(context)
        val widgetView = testRemoteViews.apply(context, FrameLayout(context))

        // Viewの情報を取得
        // テキスト情報取得
        val counterTextView = widgetView.findViewById<TextView>(R.id.counter_text)
        // 文字取得
        val counterTextString = counterTextView.text.toString()
        // 文字色取得
        val currentTextColor = counterTextView.currentTextColor

        // テスト
        // レイアウトのテスト レイアウトを出し分けている場合には有効かも
        Assert.assertEquals(R.layout.test_app_widget, testRemoteViews.layoutId)
        // 初期表示の確認 文字と色
        Assert.assertEquals("0", counterTextString)
        Assert.assertEquals(ContextCompat.getColor(context, android.R.color.black), currentTextColor)
    }

    @Test
    fun 倍数のテスト() {
        // RemoteViewに値をセット
        val testRemoteViews = TestRemoteViews(context)
        val count = "3"
        val isSpecialColor = true
        testRemoteViews.setCounterTextViewText(count, isSpecialColor)
        val widgetView = testRemoteViews.apply(context, FrameLayout(context))

        // Viewの情報を取得
        // テキスト情報取得
        val counterTextView = widgetView.findViewById<TextView>(R.id.counter_text)
        // 文字取得
        val counterTextString = counterTextView.text.toString()
        // 文字色取得
        val currentTextColor = counterTextView.currentTextColor

        // テスト
        // 表示の確認 レイアウト、文字、色
        Assert.assertEquals(R.layout.test_app_widget, testRemoteViews.layoutId)
        Assert.assertEquals("3", counterTextString)
        Assert.assertEquals(ContextCompat.getColor(context, android.R.color.holo_red_light), currentTextColor)
    }
}
```
テストを実行するとパスすることを確認できました。

#最後に
今回は簡単なテストしか書いていませんが応用も可能かと思います。
テストは大事なのでしっかりバグがないアプリを作っていきましょう。

https://qiita.com/HanaleiMoon/items/dab555b45b7f7385f7e8