LoginSignup
15
19

More than 3 years have passed since last update.

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(5)データベースなRoom編

Last updated at Posted at 2019-09-18

だいぶ空いてしまいました。
テストの実装の方でかなり手こずっていました:cold_sweat:
Robolectricは、まだまだ難しい・・・(というかcoroutineがなかなか・・・)

前回の続きです。

今回の目標

  • Roomでデータを永続化する
  • Repositoryクラスの考え方に慣れる
  • coroutineでの非同期処理の書き方を知る

データを永続化

拙記事を元にRoom用のクラスを準備していきます。
Roomやデータベースについての解説はそちらの記事に任せて、ここでは書きません。

(1) Databaseの準備

まず、dependenciesを更新します。

app/build.gradle
dependencies{
    ...

    // Room components
    implementation "android.arch.persistence.room:runtime:1.1.1"
    kapt "android.arch.persistence.room:compiler:1.1.1"
    androidTestImplementation "android.arch.persistence.room:testing:1.1.1"

    // Lifecycle components
    implementation "android.arch.lifecycle:extensions:1.1.1"
    kapt "android.arch.lifecycle:compiler:1.1.1"

    // Coroutines
    api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.0"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.0"

    ...
}

次にDatabaseの設計をしますが、今回は、StepCountLogクラスをそのまま保存することを考えます。

テーブルの設計としてはこうなります。

カラム名 説明
date String データの日付(yyyy/MM/dd)
step Integer 歩数
level String enum class LEVELを文字列化したもの
weather String enum class WEATHERを文字列化したもの

enum classの保存の仕方はいくつか候補がありますが、valueOfでenumに戻しやすいかと思い、String型のnameプロパティを扱うことにします。

1. Entityの実装

まず最初に、enum classを保存して戻すのに、そのままではRoomが処理してくれないので、TypeConverterというものを定義してやります。

class LevelConverter {

    @TypeConverter
    fun levelToString(level: LEVEL): String {
        return level.name
    }

    @TypeConverter
    fun stringToLevel(levelString: String): LEVEL {
        return LEVEL.valueOf(levelString)
    }
}

class WeatherConverter {

    @TypeConverter
    fun weatherToString(weather: WEATHER): String {
        return weather.name
    }

    @TypeConverter
    fun stringToWeather(weatherString: String): WEATHER {
        return WEATHER.valueOf(weatherString)
    }
}

LEVELからStringへ、StringからLEVELへの変換クラスと、WEATHERからString、StringからWEATHERへの変換クラスがこれでそれぞれ定義されました。

次に、StepCountLogクラスをそのままEntityとしていきます。
テーブル名やカラム名は任意ですが紛らわしくないようにしましょう。

StepCountLog.kt
@Entity(tableName = "log_table")
@TypeConverters(LevelConverter::class, WeatherConverter::class)
data class StepCountLog(
    @PrimaryKey @ColumnInfo(name = "date") val date: String,
    @ColumnInfo(name = "step") val step: Int,
    @ColumnInfo(name = "level")  val level: LEVEL = LEVEL.NORMAL,
    @ColumnInfo(name = "weather") val weather: WEATHER = WEATHER.FINE
) : Serializable

@TypeConvertersで、先ほど作った変換クラスを指定しています。

2. DAOの実装

Insert, Update, Delete, QueryAllがあればひとまずは事足りそうでしょうか。

LogDatabase.kt
@Dao
interface LogDao{

    @Insert
    fun insert(log:StepCountLog)

    @Update
    fun update(log:StepCountLog)

    @Delete
    fun delete(log:StepCountLog)

    @Query("DELETE FROM log_table")
    fun deleteAll()

    @Query("SELECT * from log_table ORDER BY date DESC")
    fun getAllLogs(): LiveData<List<StepCountLog>>
}

一応全件削除も入れてみました。まだUIがありませんけどね・・・
(それで言うと削除も更新も、UIありませんけど)

getAllLogsのクエリーSQLでは、ソートを「日付の降順」としています。
これで新しいデータほど上に表示されます。
(とはいえ、いずれデータ表示方法はリストでは無くカレンダー式のグリッド表示が良いので変更する必要があるでしょうが、そのUIはなかなかハードルが高いのでいつたどり着けるやら・・・です)

3. Room databaseの実装

LogDatabse.kt
@Database(entities = [StepCountLog::class], version = 1)
abstract class LogRoomDatabase:RoomDatabase(){
    abstract fun LogDao(): LogDao

    companion object {
        @Volatile
        private var INSTANCE: LogRoomDatabase? = null

        fun getDatabase(context: Context): LogRoomDatabase {
            return INSTANCE ?: synchronized(this) {
                // Create database here
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    LogRoomDatabase::class.java,
                    "log_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

ビルドが通ることを確認しておきましょう。
下記エラーの対処方法も、拙記事にあるとおりにすれば大丈夫です。

More than one file was found with OS independent path 'META-INF/<module_name>'

これで、データベースの準備は出来ました。

(2) リポジトリクラス

ここから、いよいよデータベースのデータを取り扱う実装部分に入っていきます。

1. リポジトリクラスって?

Googleさんが(?)提唱している、MVVMモデルの中でデータ層とVM層でのデータの橋渡しをするための、窓口用のクラス、という風に私は捉えています。
データベースだけで無く、ネットワークから取ってくるなど、そういった「データソース」(データがどこにあるか)を、VM層に意識させないためのものと言えます。
VM層がそれを意識しないことに何のメリットがあるかというと、VM層だけでテストが完結して行えるからです。
テストが大事!

一方、リポジトリクラスは、データを取得し、場合によっては必要な形に整形して、VM層に渡すことまで、が任務(テスト範囲)となります。

2. リポジトリクラスを追加する

リポジトリクラスは、今回は、Daoを介してデータベースからデータを持ってきたり入れたりするのが仕事になりますが、実装するのは単純なラッパー関数がほとんどです。

LogRepository.kt
class LogRepository(private val logDao: LogDao) {

    val allLogs: LiveData<List<StepCountLog>> = logDao.getAllLogs()

    @WorkerThread
    suspend fun insert(stepCountLog: StepCountLog) {
        logDao.insert(stepCountLog)
    }

    @WorkerThread
    suspend fun update(stepCountLog: StepCountLog){
        logDao.update(stepCountLog)
    }

    @WorkerThread
    suspend fun delete(stepCountLog: StepCountLog){
        logDao.delete(stepCountLog)
    }

    @WorkerThread
    suspend fun deleteAll(){
        logDao.deleteAll()
    }

@WorkerThreadは、UIスレッド以外から呼ばないとだめなことを明示するためのアノテーションです。Roomは、非同期でUIスレッド以外で実行することを前提としているので(回避方法はありますが、古い実装をしていて移行コードが煩雑になる場合のお助けモードと考えましょう)、付けておきます。

suspend修飾子は、coroutine内から呼ぶことが出来るようにするために必要です。

3. ViewModelでリポジトリクラスを使う

MainViewModelクラスは次のようになります。

MainViewModel.kt
class MainViewModel(app: Application) : AndroidViewModel(app) {

    // データ操作用のリポジトリクラス
    val repository: LogRepository
    // 全データリスト
    val stepCountList: LiveData<List<StepCountLog>>

    // coroutine用
    private var parentJob = Job()

    private val coroutineContext: CoroutineContext
        get() = parentJob + Dispatchers.Main

    private val scope = CoroutineScope(coroutineContext)

    init {
        val logDao = LogRoomDatabase.getDatabase(app).logDao()
        repository = LogRepository(logDao)
        stepCountList = repository.allLogs
    }

    override fun onCleared() {
        super.onCleared()
        parentJob.cancel()
    }

    fun addStepCount(stepLog: StepCountLog) = scope.launch(Dispatchers.IO){
        repository.insert(stepLog)
    }
  • コンストラクタの引数にApplicationクラスを追加
  • stepCountList変数は、直接操作しなくなるので、型をImmutableなものに変更
  • coroutine向けのCoroutineScopeを得るための処理、変数定義を追加
  • init関数でリポジトリクラスのインスタンスを作成し、全ログリストへのLiveDataの参照を受け取る
  • onCleared関数を追加でオーバーライドし、ジョブのキャンセル処理を追加
  • addStepCount関数は、リポジトリの追加関数呼び出しのみに変更

さて、実行してみましょう。最初はデータが無いので、リストには何も表示されません。
データを追加すると、リストに増えていきます。
ここまでは、これまでと変わりない動作に見えますね。
端末の戻るボタンでアプリを終了して、再起動してみましょう。

これまでは、データが消えて真っさらに戻っていましたが、一度入力したデータは残っていますね!
これでデータの永続化が出来ました。

ところで、Entityの定義のところで、プライマリーキーにdateを指定したのを覚えていますか?
※ここで嫌な予感がした方はセンスが良い・・・かな?

アプリを起動して、データを連続で登録しようとしてください。日付を変えずに。

はい、クラッシュしますね。
スタックトレースをLogcatで確認しましょう。

E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-1
    Process: jp.les.kasa.sample.mykotlinapp, PID: 7004
    android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed: log_table.date (code 1555)
        at android.database.sqlite.SQLiteConnection.nativeExecuteForLastInsertedRowId(Native Method)
        at android.database.sqlite.SQLiteConnection.executeForLastInsertedRowId(SQLiteConnection.java:788)
        at android.database.sqlite.SQLiteSession.executeForLastInsertedRowId(SQLiteSession.java:788)
        at android.database.sqlite.SQLiteStatement.executeInsert(SQLiteStatement.java:86)
        at androidx.sqlite.db.framework.FrameworkSQLiteStatement.executeInsert(FrameworkSQLiteStatement.java:51)
        at androidx.room.EntityInsertionAdapter.insert(EntityInsertionAdapter.java:64)
        at jp.les.kasa.sample.mykotlinapp.data.LogDao_Impl.insert(LogDao_Impl.java:132)
        at jp.les.kasa.sample.mykotlinapp.data.LogRepository.insert(LogRepository.kt:16)
        at jp.les.kasa.sample.mykotlinapp.MainViewModel$addStepCount$1.invokeSuspend(MainViewModel.kt:48)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(Dispatched.kt:238)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:594)
        at kotlinx.coroutines.scheduling.CoroutineScheduler.access$runSafely(CoroutineScheduler.kt:60)
        at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:742)

SQLiteConstraintException: UNIQUE constraintと言われています。
これは、dateはプライマリーキーなので、データベース上、ここのフィールドが同じ値のものは重複して登録できないのに、同じ日付でInsertしようとしたことが問題になっています。

1日に1データは拘りたいので、ここは、「すでに同じ日付のデータがあれば上書き更新する」と変えたいですね。
addStepCount関数の中で、既に持っているstepCountListから同じ日付のデータを探して、insert/update呼び出しを切り替える?
そんな愚直なことをしなくても、Roomはちゃんと対処方法があります。

Daoクラスの@Insertアノテーションに、OnConflictStrategyというのを指定すれば良いのです。

LogDao.kt
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(log: StepCountLog)

これだけ!簡単ですね。
早速連続でデータを登録してみようとしましょう。

クラッシュせず、登録済のデータの情報が置き換わったのが分かります。

でも・・・よく考えれば、同じ日付のデータは「編集画面」でやるのが正しいUIな気がします。
ということで、次は、リストの既存データをタップして、データを編集して更新出来るようにします。

なお、親切に作るなら、追加画面では、登録前に同じ日付のデータがあるかをやっぱりチェックして、あれば「上書きしますか?」みたいに聞くのが良いでしょうね。
まあそもそも、この辺のUIは、将来的にカレンダー式表示にするとなるとまた大きく変わるので、あまり拘っても仕方の無いところですが。

(このシリーズでは、圧倒的に出番の多いRecyclerView(List)の使い方を知ってもらうのも主な目的ですので、リスト表示のままにしています)

データの編集に対応する

(1) 編集画面

行をタップして編集画面を出し、データの保存や削除が出来るようにしていきます。

1. 行のタップに反応して編集画面を出す

まず、item_step_log.xmlで、行のレイアウトのルートになっているレイアウト要素に、クリック可能な設定などを追加します。

item_step_log.xml
<androidx.constraintlayout.widget.ConstraintLayout
            android:orientation="horizontal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:id="@+id/logItemLayout"
            android:foreground="?android:attr/selectableItemBackground"
            android:clickable="true"
            android:focusable="true">

android:foreground="?android:attr/selectableItemBackground"は、リップルエフェクトを付ける物です。タップしたときに分かりやすくなります。

続いて、イベントリスナーを登録して画面を起動するコードを書きます。

※Databindingでクリック処理をする関数を渡すことも可能ですが、今回は画面起動が絡んでContextが必要になるため、使いません。

コンストラクタでリスナーを渡し、その登録されたリスナーを呼び出すのは、onBindViewHolderで行います。

LogRecyclerAdapter.kt
class LogRecyclerAdapter(private val listener: OnItemClickListener) 
    ...
    interface OnItemClickListener {
        fun onItemClick(data: StepCountLog)
    }
    ...

    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        if (position >= list.size) return
        val data = list[position]
        holder.binding.stepLog = data
        holder.binding.logItemLayout.setOnClickListener {
            listener.onItemClick(data)
        }
    }

MainActivityに、このリスナーを実装させます。

MainActivity.kt
class MainActivity : AppCompatActivity(), LogRecyclerAdapter.OnItemClickListener{
    companion object {
        const val REQUEST_CODE_LOGITEM = 100

        const val RESULT_CODE_DELETE = 10
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        ....
        // RecyclerViewの初期化
        log_list.layoutManager = LinearLayoutManager(this)
        adapter = LogRecyclerAdapter(this)
        ....
    }

    override fun onItemClick(data: StepCountLog) {
        val intent = Intent(this, LogItemActivity::class.java)
        startActivityForResult(intent, REQUEST_CODE_LOGITEM)
    }
    ...
}

記録の行をクリックしたら登録画面が起動しましたか?
でも、編集なのですから、既存データが反映されてて欲しいですよね。
LogItemActivityに古いデータを渡して、初期表示するようにしましょう。

2. データの編集(上書き保存)

LogEditFragmentを作りましょう。レイアウトファイルはfragment_log_input.xmlをコピーして、ちょっと変更して使います。

※LogInputFragmentのままで、新規登録用と編集時用の表示制御を入れるのでも構いませんが、コードをスッキリさせたいので別に作ります。

できあがりはこんな感じ。

kotlin_05_01.png

fragment_log_edit.xmlのサンプルはこちらからどうぞ。
fragment_log_edit.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        xmlns:tools="http://schemas.android.com/tools">
    <data>
        <import type="jp.les.kasa.sample.mykotlinapp.data.LEVEL"/>
        <variable name="stepLog"
                  type="jp.les.kasa.sample.mykotlinapp.data.StepCountLog"/>
    </data>
    <androidx.constraintlayout.widget.ConstraintLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            tools:context=".activity.logitem.LogInputFragment">

        <TextView
                android:text="@string/label_date"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/label_date"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                app:layout_constraintTop_toTopOf="parent"
                android:layout_marginTop="16dp"/>
        <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/text_date"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toBottomOf="@+id/label_date"
                android:textSize="18sp"
                android:text="@{stepLog.date}"
                tools:text="2999/99/99"/>
        <TextView
                android:text="@string/label_step_count"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/label_step_count"
                app:layout_constraintTop_toBottomOf="@+id/text_date"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                android:layout_marginTop="16dp"/>
        <EditText
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:inputType="numberSigned"
                android:ems="10"
                android:id="@+id/edit_count"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toBottomOf="@+id/label_step_count"
                android:hint="@string/hint_edit_step"
                android:singleLine="true"
                android:textAlignment="textEnd"
                android:text="@{Integer.toString(stepLog.step)}"
                android:importantForAutofill="no" tools:targetApi="o"/>
        <TextView
                android:text="@string/label_level"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/label_level"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toBottomOf="@+id/edit_count"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"/>
        <RadioGroup
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                android:layout_marginTop="8dp"
                android:orientation="horizontal"
                app:layout_constraintTop_toBottomOf="@+id/label_level"
                android:id="@+id/radio_group"
                app:layout_constraintEnd_toEndOf="parent"
                android:layout_marginEnd="32dp">
            <RadioButton
                    android:text="@string/level_normal"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:checked="@{stepLog.level.equals(LEVEL.NORMAL)}"
                    android:id="@+id/radio_normal"/>
            <ImageView
                    android:src="@drawable/ic_sentiment_neutral_green_24dp"
                    android:layout_width="wrap_content"
                    android:layout_height="match_parent"
                    android:id="@+id/imageView"/>
            <RadioButton
                    android:text="@string/level_good"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:id="@+id/radio_good"
                    android:checked="@{stepLog.level.equals(LEVEL.GOOD)}"
                    android:layout_marginLeft="8dp"/>
            <ImageView
                    android:src="@drawable/ic_sentiment_very_satisfied_pink_24dp"
                    android:layout_width="wrap_content"
                    android:layout_height="match_parent"
                    android:id="@+id/imageView2"/>
            <RadioButton
                    android:text="@string/level_bad"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:id="@+id/radio_bad"
                    android:checked="@{stepLog.level.equals(LEVEL.BAD)}"
                    android:layout_marginLeft="8dp"/>
            <ImageView
                    android:src="@drawable/ic_sentiment_dissatisfied_black_24dp"
                    android:layout_width="wrap_content"
                    android:layout_height="match_parent"
                    android:id="@+id/imageView3"/>
        </RadioGroup>
        <TextView
                android:text="@string/label_weather"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/label_weather"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toBottomOf="@+id/radio_group"/>

        <Spinner
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:minWidth="180dp"
                android:id="@+id/spinner_weather"
                android:layout_marginTop="8dp"
                app:layout_constraintTop_toBottomOf="@+id/label_weather"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="32dp"
                android:entries="@array/array_weathers"
                app:selected="@{stepLog.weather}"/>
        <Button
                android:text="@string/update"
                android:layout_height="wrap_content"
                android:layout_width="wrap_content"
                android:id="@+id/button_update"
                app:layout_constraintTop_toBottomOf="@+id/spinner_weather"
                android:layout_marginTop="28dp"
                app:layout_constraintStart_toStartOf="parent"
                android:layout_marginStart="8dp"
                app:layout_constraintHorizontal_bias="0.5"
                app:layout_constraintEnd_toStartOf="@+id/button_delete"/>
        <Button
                android:text="@string/delete"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:id="@+id/button_delete"
                app:layout_constraintStart_toEndOf="@+id/button_update"
                app:layout_constraintEnd_toEndOf="parent"
                android:layout_marginEnd="8dp" android:layout_marginStart="8dp"
                app:layout_constraintTop_toTopOf="@+id/button_update"
                app:layout_constraintHorizontal_bias="0.5"/>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

fragment_log_input.xmlとの違いは、日付選択を出来なくしてあるのと、ボタンが「更新」と「削除」になっていることです。また、Databindingを使うようにしています。
ラジオグループのボタン選択状態をDatabindingでやる方法はもっと綺麗な方法があるかも知れませんがベタに一致を見てやることにしています。

スピナーは、単純にカスタムアダプターを作りました。

@BindingAdapter("app:selected")
fun selectWeather(view: Spinner, weather: WEATHER) {
    view.setSelection(weather.ordinal)
}

LogEditFragmentでは、argumentsからStepCountLogのデータを取り出し、Databindingにセットして(これでデータが最初からセットされた状態になります)、更新ボタン、削除ボタンのそれぞれのリスナー処理をしてActvitiyに返します。

実装サンプルはこうなります。
LogEditFragment.kt
class LogEditFragment : Fragment() {

    companion object {
        const val TAG = "LogEditFragment"
        const val ARG_DATA = "data"

        fun newInstance(stepCountLog: StepCountLog): LogEditFragment {
            val f = LogEditFragment()
            f.arguments = Bundle().apply {
                putSerializable(ARG_DATA, stepCountLog)
            }
            return f
        }
    }

    lateinit var viewModel: LogItemViewModel
    private lateinit var stepCountLog: StepCountLog

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Inflate the layout for this fragment
        val binding: FragmentLogEditBinding = DataBindingUtil.inflate(
            layoutInflater, R.layout.fragment_log_edit, container, false
        )

        viewModel = ViewModelProviders.of(activity!!).get(LogItemViewModel::class.java)

        stepCountLog = arguments!!.getSerializable(ARG_DATA) as StepCountLog

        binding.stepLog = stepCountLog

        binding.buttonUpdate.setOnClickListener {
            validation()?.let {
                val fgm = fragmentManager ?: return@setOnClickListener
                ErrorDialog.Builder().message(it).create().show(fgm, null)
                return@setOnClickListener
            }
            val dateText = text_date.text.toString()
            val stepCount = edit_count.text.toString().toInt()
            val level = levelFromRadioId(radio_group.checkedRadioButtonId)
            val weather = weatherFromSpinner(spinner_weather.selectedItemPosition)
            val stepCountLog = StepCountLog(dateText, stepCount, level, weather)
            viewModel.changeLog(stepCountLog)
        }
        binding.buttonDelete.setOnClickListener {
            viewModel.deleteLog(stepCountLog)
        }

        return binding.root
    }

    private fun validation(): Int? {
        return logEditValidation(edit_count.text.toString())
    }
}

fun logEditValidation(
    stepCountText: String?
): Int? {
    // ステップ数が1文字以上入力されていること
    if (stepCountText.isNullOrEmpty()) {
        return R.string.error_validation_empty_count
    }
    return null
}

levelFromRadioIdweatherFromSpinnerは、LogInputFragmentにあったものを、Utils.ktに移動してグローバル関数にしました。

このFragmentをどこで使うかというと、LogItemActivityです。
Intentにデータがあるかどうかで、新規登録なのか更新なのか判断して、セットするFragmentを切り分けます。

LogItemActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
    ....

        val logData = intent.getSerializableExtra(EXTRA_KEY_DATA) as StepCountLog?
        if (savedInstanceState == null) {
            // ログデータがあれば編集画面にする
            if(logData!=null){
                supportFragmentManager.beginTransaction()
                    .replace(R.id.logitem_container, LogEditFragment.newInstance(logData), LogEditFragment.TAG)
                    .commitNow()
            }else{
                supportFragmentManager.beginTransaction()
                    .replace(R.id.logitem_container, LogInputFragment.newInstance(), LogInputFragment.TAG)
                    .commitNow()
            }
        }
        ....

MainActivityからLogItemActivityを起動するIntentに、StepCountLogデータを付けてあげましょう。

MainActivity.kt
    override fun onItemClick(data: StepCountLog) {
        val intent = Intent(this, LogItemActivity::class.java)
        intent.putExtra(LogItemActivity.EXTRA_KEY_DATA, data)
        startActivityForResult(intent, REQUEST_CODE_LOGITEM)
    }

最後に、LogItemViewModelに必要な変数、関数を追加します。

LogItemViewModel.kt
    private val _deleteLog = MutableLiveData<StepCountLog>()
    val deleteLog = _deleteLog as LiveData<StepCountLog>

    @UiThread
    fun deleteLog(data: StepCountLog) {
        _deleteLog.value = data
    }

起動して、リストの行をタップしてみます。

既存のデータがちゃんと入っていますか?ラジオボタンや、天気のスピナーはちゃんと選択されていますか?

変更して、登録ボタンを押してみましょう。メイン画面に戻ると、ちゃんと更新されているかと思います。

同様に、削除でリストから消え・・・ませんね。
理由は分かりますでしょうか?

LogItemActivity.kt

        viewModel.deleteLog.observe(this, Observer{
            val dataIntent = Intent()
            dataIntent.putExtra(EXTRA_KEY_DATA, it)
            setResult(MainActivity.RESULT_CODE_DELETE, dataIntent)
            finish()
        })

MainActivity.kt
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {

        when (requestCode) {
            REQUEST_CODE_LOGITEM -> {
                onNewStepCountLog(resultCode, data)
                return
            }
        }

        super.onActivityResult(requestCode, resultCode, data)
    }

    private fun onNewStepCountLog(resultCode: Int, data: Intent?) {
        when (resultCode) {
            RESULT_OK -> {
                val log = data!!.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
                viewModel.addStepCount(log)
            }
        }
    }

RESULT_CODE_DELETEで返した場合に対して、MainActivityonActivityResultで処理をしていないからですね。ここに追加して上げましょう。

そうなると、onNewStepCountLogという名前がそぐわなくなるので、適当に変えます。右クリック-[Refactor]-[Rename]で呼び出し元も変えてくれるので便利です。

MainActivity.kt
    private fun onStepCountLogChanged(resultCode: Int, data: Intent?) {
        when (resultCode) {
            RESULT_OK -> {
                val log = data!!.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
                viewModel.addStepCount(log)
            }
            RESULT_CODE_DELETE ->{
                val log = data!!.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
                viewModel.deleteStepCount(log)
            }
        }
    }

MainViewModelには、deleteStepCount関数を追加します。

MainViewModel.kt
    fun deleteStepCount(stepLog: StepCountLog) = scope.launch(Dispatchers.IO) {
        repository.delete(stepLog)
    }

これでデータも削除出来るようになりました。

※横向き用のレイアウトがある場合は、併せて忘れずに編集しておきましょうね。

(2) データの削除を簡単にする

さて、削除したいときに、いちいちデータの詳細画面まで行くのは面倒ですね。
Android的なお作法だと、以下のどちらかが標準的かなと思います。

  • 長押しで削除
  • スワイプで削除

スワイプはGmailアプリなんかであるやつですね。難易度としては前者の方が簡単です。
テストも前者の方が簡単な気がするので、今回は長押しでやっていきます。

スワイプで削除が気になる方は、ItemTouchHelperでググって見て下さい。

長押しに反応するには、setOnLongClickListenerでViewに対してリスナーを登録します。

class LogRecyclerAdapter ...{
    ....
    override fun onBindViewHolder(holder: LogViewHolder, position: Int) {
        ....
        holder.binding.logItemLayout.setOnLongClickListener {
            listener.onLongItemClick(data)
            return@setOnLongClickListener true
        }
    }
    ....
}

trueを返しているのは、OnLongClickListener#onLongClickの戻り値で、「trueならコールバックを消費する」という意味になります。
消費する、というのが分かりづらいですが、「ここでこの長押しに対する処理を止める」と捉えれば良いかと思います。falseを返すと、例えばViewが重なっているときなど、下にあるViewが長押し可能でやはりコールバックを登録しているとき、そちらも呼ばれることになります(多分。あまりそういう場面=Viewが重なっている場面に出くわさないので・・・)

MainActivityで、onLongItemClickを実装しましょう。

MainActivity.kt

    override fun onLongItemClick(data: StepCountLog) {
        // ダイアログを表示
        val dialog = ConfirmDialog.Builder()
            .message(R.string.message_delete_confirm)
            .data(Bundle().apply {
                putSerializable(DIALOG_BUNDLE_KEY_DATA, data)
            })
            .create()
        dialog.show(supportFragmentManager, DIALOG_TAG_DELETE_CONFIRM)
    }

いきなり削除は不親切なので、ダイアログを呼び出して、確認を入れています。(さっきの削除ボタンにも入れるのが本来はベスト)
ConfirmDialogクラスは、汎用性を持たせるため、以下のような実装にしました。

ConfirmDialog.kt
class ConfirmDialog : DialogFragment(), DialogInterface.OnClickListener {

    interface ConfirmEventListener {
        /**
         * 確認ダイアログのコールバック<br>
         * @param which : AlertDialogの押されたボタン(POSITIVE or NEGATIVE)
         * @param bundle : data()でセットしたBundleデータ
         * @param requestCode : targetFragmentと併せて指定したrequestCode
         */
        fun onConfirmResult(which: Int, bundle: Bundle?, requestCode: Int)
    }

    class Builder() {
        private var message: String? = null
        private var messageResId: Int = 0
        private var target: Fragment? = null
        private var requestCode: Int = 0
        private var data: Bundle? = null

        fun message(message: String): Builder {
            this.message = message
            return this
        }

        fun message(resId: Int): Builder {
            this.messageResId = resId
            return this
        }

        fun target(fragment: Fragment): Builder {
            this.target = fragment
            return this
        }

        /**
         * only for targetFragment
         */
        fun requestCode(requestCode: Int): Builder {
            this.requestCode = requestCode
            return this
        }

        fun data(bundle: Bundle): Builder {
            this.data = bundle
            return this
        }

        fun create(): ConfirmDialog {
            val d = ConfirmDialog()
            d.arguments = Bundle().apply {
                if (message != null) {
                    putString(KEY_MESSAGE, message)
                } else {
                    putInt(KEY_RESOURCE_ID, messageResId)
                }
                if (data != null) {
                    putBundle(KEY_DATA, data)
                }
            }
            if (target != null) {
                d.setTargetFragment(target, requestCode)
            }
            return d
        }
    }

    companion object {
        const val KEY_MESSAGE = "message"
        const val KEY_RESOURCE_ID = "res_id"
        const val KEY_DATA = "data"
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // AlertDialogで作成する
        val builder = AlertDialog.Builder(requireContext())

        // メッセージの決定
        val message =
            when {
                arguments!!.containsKey(KEY_MESSAGE) -> arguments!!.getString(KEY_MESSAGE)
                else -> requireContext().getString(
                    arguments!!.getInt(KEY_RESOURCE_ID)
                )
            }
        // AlertDialogのセットアップ
        builder.setMessage(message)
            .setTitle(R.string.confirm)
            .setIcon(android.R.drawable.ic_dialog_info)
            .setNegativeButton(android.R.string.no, this)
            .setPositiveButton(android.R.string.yes, this)
        return builder.create()
    }

    override fun onClick(dialog: DialogInterface?, which: Int) {
        val data = arguments!!.getBundle(KEY_DATA)
        if (targetFragment is ConfirmEventListener) {
            val listener = targetFragment as ConfirmEventListener
            listener.onResult(which, data, targetRequestCode)
            return
        } else if (activity is ConfirmEventListener) {
            val listener = activity as ConfirmEventListener
            listener.onResult(which, data, targetRequestCode)
            return
        }
        Log.e("ConfirmDialog", "Target Fragment or Activity should implement ConfirmEventListener!!")
    }

}

このクラスの概要としては次のようになります。

  • メッセージを指定できる
  • [YES] or [No]のボタンを表示する
  • Bundleデータを設定できる
  • 呼び出し元のFragmentかActivityで、ConfirmEventListenerを実装してあれば、ボタンイベントのコールバックを受け取る

DialogFragmentのコールバックの受け取り方は色々なアプローチがあるのですが、ここでは「呼び出し元がコールバックインターフェースを実装している」ことを前提とした作りにしました。
onAttachとかでチェックしてわざとクラッシュさせるパターンもありますが、仮に実装が漏れていても落ちないでエラーログが出るだけにしています。
この辺りは設計思想や、開発に関わる人数、そのレベル感によっていろんなアプローチがあると思います。
拘り始めると根が深い部分で、いろんな提言をしている人がいますので、時間があれば調べてみると良いかと思います。

ということで、MainActivityConfirmEventListenerを実装させます。

MainActivity.kt
class MainActivity : AppCompatActivity()
    , LogRecyclerAdapter.OnItemClickListener
    , ConfirmDialog.ConfirmEventListener {

    ...    

    override fun onConfirmResult(which: Int, bundle: Bundle?, requestCode: Int) {
        when (which) {
            DialogInterface.BUTTON_POSITIVE -> {
                // 削除を実行
                val stepCountLog = bundle?.getSerializable(DIALOG_BUNDLE_KEY_DATA) as StepCountLog?
                viewModel.deleteStepCount(stepCountLog!!)
            }
        }
    }
} 

これで、長押し後、「はい」をタップするとデータが消えるようになりました。

テスト

これまで通り、ここまでのUnitTest、UITestを書いていきましょう。

最低限、これくらいは必要でしょうか?

  • リポジトリクラスのテスト

    • データの追加、更新、削除、全削除が出来ること
  • メイン画面のテスト

    • 既存データの行をタップすると編集画面が起動する
      • 変更が戻るとリストに反映される
      • 削除が戻るとリストに反映される
    • 既存データの行を長押しすると削除ダイアログが出る
      • キャンセルで削除しない
      • OKで削除する
  • 編集画面のテスト

    • 初期表示のテスト(渡されたデータを初期値としてセットしているか)
    • 変更を正しく結果にセットしている
    • 削除を正しく結果にセットしている
    • Validationのテスト

(1) MainViewModelTestの修正

各テストの実装に入る前に、MainViewModelの初期化にApplicationが引数が増えているため、テスト全体のコンパイルが通らなくなっていますのでそこを直しておきます。
また、Contextが必要になっているため、JUnitではテストできません。さらに、スレッドの問題で、Robolectricで上手く実行できる実装を見つけられませんでした。(※1)

そこで今回は、残念ですが、androidTestに変更して、実行できるようにします。

※1: coroutine,testでググるとそれなりに情報は出てくるのですが、少しずつ古く(async/awitを使ったものがほとんど)、experimentalなライブラリ(※2)を使えば解決できそうではあるのですが、今回はそういうものは使いたくないので、いったんは目をつぶることにします。
代わりに、UIテストで動作を確認できれば、それで良しとすることにします。

※2: kotlinx-coroutines-testは、Robolectricでスレッド絡みでテストできないという情報もあります。

1. build.gradleの変更

前準備として、多分次の依存ライブラリを追加しておかないとテストが実行できないかと思います。

app/build.gradle
dependencies{
    ...
    implementation "androidx.exifinterface:exifinterface:1.0.0"
    implementation "androidx.legacy:legacy-support-core-ui:1.0.0"
    implementation "androidx.legacy:legacy-support-core-utils:1.0.0"
    ...

    // Room components
    def room_version = "1.1.1"
    implementation "android.arch.persistence.room:runtime:$room_version"
    kapt "android.arch.persistence.room:compiler:$room_version"
    androidTestImplementation "android.arch.persistence.room:testing:$room_version"
    androidTestImplementation "androidx.room:room-testing:$room_version"
    testImplementation "android.arch.persistence.room:testing:$room_version"
    testImplementation "androidx.room:room-testing:$room_version"

    ....
}

2. 初期化の変更

androidTestに変更するには、testフォルダから、androidTestフォルダにファイルを移動させるだけです。

また、@RunWith(AndroidJUnit4::class)をクラス宣言に追加します。

MainViewModelTest.kt
@RunWith(AndroidJUnit4::class)
class MainViewModelTest {
    ....
    @Before
    fun setUp() {
        val appContext = ApplicationProvider.getApplicationContext<Application>()

        viewModel = MainViewModel(appContext)
    }
    ....

3. 各テストの修正と追加

initテストは、LiveDataの更新待ちをする必要が生じるため、次のように書き換えます。

MainViewModelTest.kt
    @Test
    fun init() {
        assertThat(viewModel.repository)
            .isNotNull()
        assertThat(viewModel.stepCountList)
            .isNotNull()
        viewModel.stepCountList.observeForTesting {
            assertThat(viewModel.stepCountList.value)
                .isEmpty()
        }
    }

observeForTestingという関数は、下記のような拡張関数です。

TestUtils.kt
fun <T> LiveData<T>.observeForTesting(block: () -> Unit) {
    val observer = Observer<T> { Unit }
    try {
        observeForever(observer)
        block()
    } finally {
        removeObserver(observer)
    }
}

通常LiveDataをobserveするにはライフサイクルが必要になりますが、observeForeverを使うと不要になります。これを利用して、LiveDataの変更があればblockで渡されたlamda関数が実行されます。

addStepCountテストは、次のようになります。

MainViewModelTest.kt
    @Test
    fun addStepCount() {
        val listObserver = TestObserver<List<StepCountLog>>(2)
        viewModel.stepCountList.observeForever(listObserver)

        runBlocking {
            viewModel.addStepCount(StepCountLog("2019/06/21", 123))
            viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        listObserver.await()

        assertThat(viewModel.stepCountList.value)
            .isNotEmpty()

        val list = viewModel.stepCountList.value as List<StepCountLog>
        assertThat(list.size).isEqualTo(2)
        assertThat(list[0]).isEqualToComparingFieldByField(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        assertThat(list[1]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))

        viewModel.stepCountList.removeObserver(listObserver)
    }

runBlockingは、現在のスレッドをブロックして、中の処理を実行してから戻してくれます。

リスト表示の検証部分では、リストが日付の降順で取ってくるように変わったので、テストでもindexを逆にしています。注意してください。

TestObserverクラスは次のような物です。

TestUtils.kt
class TestObserver<T>(count: Int = 1) : Observer<T> {

    private val latch: CountDownLatch = CountDownLatch(count)

    override fun onChanged(t: T?) {
        latch.countDown()
    }

    fun await(timeout: Long = 6, unit: TimeUnit = TimeUnit.SECONDS) {
        if (!latch.await(timeout, unit)) {
            throw TimeoutException()
        }
    }
}

CountDownLatchというのは、その処理が完了するか、指定時間が経過するまで待ちたい、という用途で使えます。
上記の実装は、指定の回数、監視しているLiveDataonChangedが呼ばれると終了する、という動作となっています。

addStepCountテストでは、2回データを投入しているので、val listObserver = TestObserver<List<StepCountLog>>(2)と2回変更があるまで待つことになります。

削除関数も増えているので、それのテストも追加します。

MainViewModel.kt
    @Test
    fun deleteStepCount(){
        val listObserver = TestObserver<List<StepCountLog>>(3)
        viewModel.stepCountList.observeForever(listObserver)

        runBlocking {
            viewModel.addStepCount(StepCountLog("2019/06/21", 123))
            viewModel.addStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
            Thread.sleep(500)
            viewModel.deleteStepCount(StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        listObserver.await()

        assertThat(viewModel.stepCountList.value)
            .isNotEmpty()

        val list = viewModel.stepCountList.value as List<StepCountLog>
        assertThat(list.size).isEqualTo(1)
        assertThat(list[0]).isEqualToComparingFieldByField(StepCountLog("2019/06/21", 123))

        viewModel.stepCountList.removeObserver(listObserver)
    }

やっていることは追加メソッドのテストとほぼ同じなので説明はしなくて大丈夫かと思いますが、削除の前にsleepを入れているのは、LiveDataの変更が3回ちゃんと飛んでこないことがあって、どうも、連続したデータの追加と削除が、結果的に「なかったこと」になっているようです。
恐らく、Databaseへの書込を完了して、次の削除を実行する間に、LiveDataへのpostValueが間に合っておらず、1回分飛ばされた状態なのかな、と。あまり詳しく検証していませんが、Roomが非同期で動いているのでそういうこともあり得るかな、と。
そんなわけで削除の前に少し時間をおいて、LiveDataのpostが行われる猶予を持たせています。

(2) リポジトリクラスのテスト

こちらもcoroutineが絡んでるけど、テスト書けるの?と思ったあなた。
大丈夫なんです。リポジトリクラスのメソッドはsuspendな関数なだけで、スレッドを切り替えてはいないので(切り替えて非同期で実行できるようにするためにsuspend修飾子を付けてはいますが)、先ほども出てきたrunBlockingを使うだけで行けちゃいます。

ということで、早速書いてみます。

1. 準備

LogDaoクラスに日付をキーにデータを取得するクエリーメソッドを追加します。

LogDao.kt
    @Query("SELECT * from log_table WHERE date = :srcDate")
    fun getLog(srcDate: String): StepCountLog

LiveDataである必要は無いので、単にStepCountLogを返すようにしています。

尚、LogDaoクラスのテストは、Roomの機能の範疇でしょってことで書きません。
(ここをテスト書いてたらキリがない・・・)

2. テストクラス

LogRepositoryTestを作成します。Robolectricでテスト可能なので、test下に作りましょう。
@RunWith(AndroidJUnit4::class)を忘れずに付けます。
メンバにデータベースやLogDaoクラス、リポジトリクラスを持つようにします。

LogRepositoryTest.kt
@RunWith(AndroidJUnit4::class)
class LogRepositoryTest {
    private lateinit var database: LogRoomDatabase
    private lateinit var logDao: LogDao
    private lateinit var repository: LogRepository
}

データベースの作成はちょっと特殊です。

LogRepositoryTest.kt
    @Before
    fun setUp() {
        database = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext<Context>(),
            LogRoomDatabase::class.java
        ).allowMainThreadQueries().build()
        logDao = database.logDao()
        repository = LogRepository(logDao)
    }

    @After
    fun tearDown() {
        database.close()
    }

テストの度に毎回データベースを作成します。この時、Room.inMemoryDatabaseBuilderを使ってメモリ内にだけ作ります。毎回ちゃんとストレージに作っては消し、とやるのは無駄になるからですね。
それと、テストなのでメインスレッドで実行しちゃえ、ということで、allowMainThreadQueriesもしています。

で、毎回テスト終わるときにはDatabaseをcloseしています。

早速、insertのテストを書いてみましょう。

LogRepositoryTest.kt
    @Test
    fun insert() {
        runBlocking {
            repository.insert(StepCountLog("2019/08/30", 12345, LEVEL.GOOD, WEATHER.CLOUD))
        }

        val item = logDao.getLog("2019/08/30")
        assertThat(item).isEqualToComparingFieldByField(
            StepCountLog("2019/08/30", 12345, LEVEL.GOOD, WEATHER.CLOUD)
        )
    }

insertはsuspendな関数なので、runBlockingでブロック実行します。

その後で、logDaoから直接getLogでデータを取り出して期待値通りかチェックしています。

同じように他の関数もテストを書いていきます。

LogRepositoryTest.kt
    @Test
    fun update() {
        runBlocking {
            repository.insert(StepCountLog("2019/08/30", 12345, LEVEL.GOOD, WEATHER.CLOUD))
            repository.update(StepCountLog("2019/08/30", 12344))
        }

        val item = logDao.getLog("2019/08/30")
        assertThat(item).isEqualToComparingFieldByField(
            StepCountLog("2019/08/30", 12344, LEVEL.NORMAL, WEATHER.FINE)
        )
    }

    @Test
    fun delete() {
        runBlocking {
            repository.insert(StepCountLog("2019/08/30", 12345))
            repository.delete(StepCountLog("2019/08/30", 12345))
        }

        val item = logDao.getLog("2019/08/30")
        assertThat(item).isNull()
    }

    @Test
    fun getAllLogs() {
        runBlocking {
            repository.insert(StepCountLog("2019/08/30", 12345))
            repository.insert(StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD))
        }

        val items = repository.allLogs
        items.observeForever {
            assertThat(items.value).isNotEmpty()
            assertThat(items.value!!.size).isEqualTo(2)
            assertThat(items.value!![1]).isEqualToComparingFieldByField(
                StepCountLog("2019/08/30", 12345))
            assertThat(items.value!![0]).isEqualToComparingFieldByField(
                StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD))
        }
    }

    @Test
    fun deleteAll() {
        runBlocking {
            repository.insert(StepCountLog("2019/08/30", 12345))
            repository.insert(StepCountLog("2019/08/31", 12345, LEVEL.GOOD, WEATHER.CLOUD))
            repository.deleteAll()
        }

        val items = repository.allLogs
        items.observeForever {
            assertThat(items.value).isEmpty()
        }
    }

observeForeverがここでも大活躍。

2020/02/09追記

LiveDataのテストをしているのに、InstantTaskExecutorRule()の指定の追加を忘れていました。このままだと、一見テスト通過しているように見えますが、実際にはobserveForeverの中が実行されていません。
クラスの先頭に、以下を追加して下さい。

LogRepositoryTest.kt
    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

(2) メイン画面のテスト

こちらも、Roomのスレッド処理が上手くいかないようでRobolectricだとエラーになります。なので、andoridTestの方に実装していきます。

(勢い込んでRobolectric導入したけど、結局andoridTestもまだまだ必要で、混在しているという分かりづらい状況になっちゃいました。
時期尚早でしたかね。残念無念。)

とりあえず、各テストの最初と最後にデータをクリアするように、こちらもsetUptearDown関数を書いておきます。
今回は、リポジトリクラスからdeleteAllするのではなくて、アプリケーション全体で持っているデータベースをまるっと削除します。

MainActivityTestI.kt
    @get:Rule
    val activityRule = ActivityTestRule(MainActivity::class.java, false, false)

    @Before
    fun setUp() {
        val appContext = ApplicationProvider.getApplicationContext<Application>()

        // 最初にデータを削除する
        appContext.deleteDatabase(DATABASE_NAME)

        activityRule.launchActivity(null)
    }

    @After
    fun tearDown() {
        activityRule.finishActivity()

        // 最後にデータを削除する
        val appContext = ApplicationProvider.getApplicationContext<Application>()
        appContext.deleteDatabase(DATABASE_NAME)
    }

データベースの削除が、Activity起動の前に行われて欲しいので、Ruleも少し変更しました。
3つ目の引数がfalseなので、自動でActivityは起動しません。データベースを削除した後、時前でlaunchActivityしています。ここでMainActivityが起動します。
終了時は、逆にActivityを閉じてから、データベースをまるごと削除しています。

あ、DATABASE_NAMEは適当な場所で宣言しておきましょう。

LogDatabase.kt
const val DATABASE_NAME = "log_database"

@Database(entities = [StepCountLog::class], version = 1)
abstract class LogRoomDatabase : RoomDatabase() {
    abstract fun logDao(): LogDao

    companion object {

        @Volatile
        private var INSTANCE: LogRoomDatabase? = null

        fun getDatabase(context: Context): LogRoomDatabase {
            return INSTANCE ?: synchronized(this) {
                // Create database here
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    LogRoomDatabase::class.java,
                    DATABASE_NAME
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

1. 既存テストの修正

addRecortListは、複数行のリスト表示の確認テストですが、テスト名が分かりにくかったので変えます。
そして、日付の降順に表示順が変わっているのでそこも合わせます。

MainActivityTestI.kt
    @Test
    fun showList() {
        // ViewModelのリストに直接追加
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // リストの表示確認
        var index = 1

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("12345"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/13"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_wb_sunny_yellow_24dp),R.id.weatherImageView)))
            // @formatter:on
        index = 0
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp),R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_iconmonstr_umbrella_1),R.id.weatherImageView)))
        // @formatter:on
    }

2. 既存データの行をタップで編集画面が起動するテスト

既存データの行をタップしたときに、編集画面が起動しているかのチェックをテストにします。

MainActivityTestI.kt
    @Test
    fun onClickListItem() {
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // 監視モニター
        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false
        )
        getInstrumentation().addMonitor(monitor)


        var index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(index, click()))
        // @formatter:on
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // ResultActivityが起動したか確認
        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L)
        assertThat(monitor.hits).isEqualTo(1)
        assertThat(resultActivity).isNotNull()

        // その起動Intentに必要な情報があるかチェック
        val extraData = resultActivity.intent.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA) as StepCountLog
        assertThat(extraData)
            .isEqualToComparingFieldByField(
                StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN)
            )
    }

編集画面のIntentに必要な情報がセットされているか、までのテストとしています。Intentに設定されたテータが実際に編集画面に反映されているかは、LogItemActivityのテストとしたいからです。

3. 編集画面の戻り(追加)テストの修正

onActicityResultテストの名前を変えておきます(変更と削除のテストを追加するため)。
後はそのままです。

MainActivityTestI.kt
    @Test
    fun onActivityResult_Add() {
        val resultData = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.SNOW))
        }

        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false
        )
        getInstrumentation().addMonitor(monitor)

        // 登録画面を起動
        onView(
            Matchers.allOf(withId(R.id.add_record), withContentDescription("記録を追加"))
        ).perform(click())

        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 500L)
        resultActivity.setResult(Activity.RESULT_OK, resultData)
        resultActivity.finish()

        // 反映を確認
        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_grain_gley_24dp),R.id.weatherImageView)))
            // @formatter:on
    }

4. 編集画面の戻り(更新)テスト

これはさっきの「追加の戻り」テストコードとほぼ同じですが、最初にデータを投入しておくところが違います。

MainActivityTestI.kt
    @Test
    fun onActivityResult_Edit() {
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        val resultData = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/19", 5000, LEVEL.NORMAL, WEATHER.CLOUD))
        }

        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false
        )
        getInstrumentation().addMonitor(monitor)

        // 編集画面を起動
        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(index, click()))
        // @formatter:on
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()


        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 500L)
        resultActivity.setResult(Activity.RESULT_OK, resultData)
        resultActivity.finish()

        // 反映を確認
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("5000"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_neutral_green_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_cloud_gley_24dp),R.id.weatherImageView)))
            // @formatter:on
    }

1行目をタップし、編集画面の戻りIntentに、日付以外の値を変えたStepCountLogをセットします。その後、表示上ちゃんと変わっていることのチェックをしています。

5. 編集画面の戻り(削除)テスト

こちらも追加や変更とほぼ同じで、戻りのIntentを削除用の物とし、表示が変わっていることをチェックします。

MainActivityTestI.kt
    @Test
    fun onActivityResult_Delete() {
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        val resultData = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }

        val monitor = Instrumentation.ActivityMonitor(
            LogItemActivity::class.java.canonicalName, null, false
        )
        getInstrumentation().addMonitor(monitor)

        // 編集画面を起動
        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(index, click()))
        // @formatter:on
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()


        val resultActivity = getInstrumentation().waitForMonitorWithTimeout(monitor, 500L)
        resultActivity.setResult(MainActivity.RESULT_CODE_DELETE, resultData)
        resultActivity.finish()

        // 反映を確認
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("12345"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/13"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_wb_sunny_yellow_24dp),R.id.weatherImageView)))
            // @formatter:on
    }

1行目のデータを削除したので、1行目のデータが繰り上がっている確認をしています。

6. 既存データの行を長押しで削除のテスト

以下を確認するテストを作ります。

  • 既存データの行を長押しして、削除ダイアログが出るテスト
  • 削除ダイアログのキャンセルで削除されないことのテスト
  • 削除ダイアログのOKで削除されることのテスト

長押しクリックは、ViewActions.longClick()を使います。
これまで書いてきたテストでやってきた内容で書けるので、是非一度自力で書いてみてください。

サンプルはこちらからどうぞ。
MainActivityTestI.kt
    @Test
    fun onLongClickListItem_cancel_back() {
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(index, longClick()))
        // @formatter:on
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // Dialogの表示確認
        onView(withText(R.string.message_delete_confirm))
            .check(matches(isDisplayed()))
        onView(withText(android.R.string.yes))
            .check(matches(isDisplayed()))
        onView(withText(android.R.string.no))
            .check(matches(isDisplayed()))

        // 端末戻るボタン
        pressBack()

        // Dialogの非表示を確認
        onView(withText(R.string.message_delete_confirm))
            .check(doesNotExist())

        // 削除されてないことの確認
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_iconmonstr_umbrella_1),R.id.weatherImageView)))
            // @formatter:on
    }

    @Test
    fun onLongClickListItem_cancel() {
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(index, longClick()))
        // @formatter:on
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // Dialogキャンセル
        onView(withText(R.string.message_delete_confirm))
            .check(matches(isDisplayed()))
        onView(withText(android.R.string.no))
            .perform(click())

        // Dialogの非表示を確認
        onView(withText(R.string.message_delete_confirm))
            .check(doesNotExist())

        // 削除されてないことの確認
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("666"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/19"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_iconmonstr_umbrella_1),R.id.weatherImageView)))
            // @formatter:on
    }

    @Test
    fun onLongClickListItem_delete() {
        // 最初にデータ投入
        val mainActivity = activityRule.activity

        mainActivity.runOnUiThread {
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/13", 12345, LEVEL.GOOD))
            mainActivity.viewModel.addStepCount(StepCountLog("2019/06/19", 666, LEVEL.BAD, WEATHER.RAIN))
        }
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        val index = 0

        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(index, longClick()))
        // @formatter:on
        InstrumentationRegistry.getInstrumentation().waitForIdleSync()

        // Dialogの表示確認
        onView(withText(R.string.message_delete_confirm))
            .check(matches(isDisplayed()))
        onView(withText(android.R.string.yes))
            .perform(click())

        // Dialogの非表示を確認
        onView(withText(R.string.message_delete_confirm))
            .check(doesNotExist())

        // 反映を確認
        onView(withId(R.id.log_list))
            // @formatter:off
            .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(index))
            .check(matches(atPositionOnView(index, withText("12345"), R.id.stepTextView)))
            .check(matches(atPositionOnView(index, withText("2019/06/13"), R.id.dateTextView)))
            .check(matches(atPositionOnView(index,
                withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp), R.id.levelImageView)))
            .check(matches(atPositionOnView(index,
                        withDrawable(R.drawable.ic_wb_sunny_yellow_24dp),R.id.weatherImageView)))
            // @formatter:on
    }

(3) 編集画面のテスト

最後に編集画面のテストです。
この画面では、新たにIntentExtraDataがあればLogEditFragmentがセットされるようになりました。
Fragmentのテストは単体で書く方法もあるようなのですが、ここはActivityのテストの中でやってしまうことにします。
Fragmentでやることがそれぞれに複雑になってくるような場合には、FragmentScenaioというのを使ってテストをした方が良い場合もあるでしょう。詳しくはこちらを参考にしてください。

※私も使ってみようとしたのですが、どうしたわけか依存関係が解決できないエラーが出て解消できなかったので、使用を断念しています。。。
GralePluginのバージョンなどが関係しているんでしょうかね…

1. 編集画面の起動状態のテスト

LogEditFragmentなのかどうかは、表示内容で判断していきます。

LogItemActivityTestI.kt
    /**
     *   LogEditFragmentの初期表示をチェックする
     */
    @Test
    fun logEditFragment() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)

        // 日時ラベル
        onView(withText(R.string.label_date)).check(matches(isDisplayed()))
        // 日付
        onView(withText("2019/06/22")).check(matches(isDisplayed()))
        // 日付選択ボタン(非表示)
        onView(withText(R.string.label_select_date)).check(doesNotExist())
        // 歩数ラベル
        onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
        // 歩数
        onView(withText("456")).check(matches(isDisplayed()))
        // 気分ラベル
        onView(withText(R.string.label_level)).check(matches(isDisplayed()))
        // 気分ラジオボタン
        onView(withText(R.string.level_normal)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withText(R.string.level_good)).check(matches(isDisplayed()))
        onView(withText(R.string.level_bad)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
        onView(withId(R.id.imageView))
            .check(matches(withDrawable(R.drawable.ic_sentiment_neutral_green_24dp)))
            .check(matches(isDisplayed()))
        onView(withId(R.id.imageView2))
            .check(matches(withDrawable(R.drawable.ic_sentiment_very_satisfied_pink_24dp)))
            .check(matches(isDisplayed()))
        onView(withId(R.id.imageView3))
            .check(matches(withDrawable(R.drawable.ic_sentiment_dissatisfied_black_24dp)))
            .check(matches(isDisplayed()))
        // 天気ラベル
        onView(withText(R.string.label_step_count)).check(matches(isDisplayed()))
        // 天気スピナー
        onView(withId(R.id.spinner_weather)).check(matches(isDisplayed()))
        onView(withText("暑い")).check(matches(isDisplayed()))
        // 登録ボタン
        onView(withText(R.string.update)).check(matches(isDisplayed()))
        // 削除ボタン
        onView(withText(R.string.delete)).check(matches(isDisplayed()))
    }

それ以外の動作はほぼ同じコードになりますが、別のFragmentでの動作なのでやっぱり書いておきます。
ほとんどコピペで書けるでしょうが、Activity起動コード、初期値などが微妙に違いますのでその辺に注意してください。

サンプルはこちらからどうぞ。
LogItemActivityTestI.kt
    /**
     * 編集画面:ラジオボタン[GOOD]を押したときのテスト
     */
    @Test
    fun logEdit_levelRadioButtonGood() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)

        onView(withId(R.id.radio_good)).perform(click())

        // 選択状態
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
    }

    /**
     * 編集画面:ラジオボタン[NORMAL]を押したときのテスト
     */
    @Test
    fun logEdit_levelRadioButtonNormal() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)


        onView(withId(R.id.radio_normal)).perform(click())

        // 選択状態
        onView(withId(R.id.radio_bad)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_good)).check(matches(isDisplayed()))
            .check(matches(not(isChecked())))
        onView(withId(R.id.radio_normal)).check(matches(isDisplayed()))
            .check(matches(isChecked()))
    }

    /**
     * 編集画面:スピナーを押したときのテスト
     */
    @Test
    fun logEdit_weatherSpinner() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)

        // 初期表示
        onView(withText("暑い")).check(matches(isDisplayed()))

        onView(withId(R.id.spinner_weather)).perform(click())

        // リスト表示を確認
        onView(withText("晴れ")).check(matches(isDisplayed()))
        onView(withText("雨")).check(matches(isDisplayed()))
        onView(withText("曇り")).check(matches(isDisplayed()))
        onView(withText("雪")).check(matches(isDisplayed()))
        onView(withText("寒い")).check(matches(isDisplayed()))
        onView(withText("暑い")).check(matches(isDisplayed()))

        // 初期値以外を選択
        onView(withText("雨")).perform(click())

        onView(withText("暑い")).check(doesNotExist())
        onView(withText("雨")).check(matches(isDisplayed()))
    }

    /**
     * 編集画面:更新ボタン押下のテスト:正常
     */
    @Test
    fun updateButton_success() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)

        onView(withId(R.id.edit_count)).check(matches(isDisplayed()))
            .perform(replaceText("12345"))
        onView(withId(R.id.radio_good)).perform(click())

        onView(withId(R.id.spinner_weather)).perform(click())
        onView(withText("曇り")).perform(click())

        onView(withId(R.id.button_update)).check(matches(isDisplayed()))
            .perform(click())

        assertThat(activityRule.activityResult.resultCode).isEqualTo(Activity.RESULT_OK)
        assertThat(activityRule.activityResult.resultData).isNotNull()
        val data = activityRule.activityResult.resultData.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA)
        assertThat(data).isNotNull()
        assertThat(data is StepCountLog).isTrue()
        val expectItem = StepCountLog("2019/06/22", 12345, LEVEL.GOOD, WEATHER.CLOUD)
        assertThat(data).isEqualToComparingFieldByField(expectItem)
    }

    /**
     * 編集画面:更新ボタン押下のテスト:カウント未入力エラー
     */
    @Test
    fun updateButton_error_emptyCount() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)

        onView(withId(R.id.edit_count)).perform(replaceText(""))

        onView(withId(R.id.button_update)).check(matches(isDisplayed()))
            .perform(click())

        onView(withText(R.string.error_validation_empty_count)).check(matches(isDisplayed()))
    }

2. 削除ボタンのテスト

削除ボタンのテストも、更新のテストと同じように、戻そうとしているResultCode/ResultIntentの内容が正しいかのチェックとします。

LogItemActivityTestI.kt
   /**
     * 編集画面:削除ボタン押下のテスト:カウント未入力エラー
     */
    @Test
    fun deleteButton() {
        // データをセットしてから起動
        val intent = Intent().apply {
            putExtra(LogItemActivity.EXTRA_KEY_DATA, StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT))
        }
        activity = activityRule.launchActivity(intent)

        onView(withId(R.id.button_delete)).check(matches(isDisplayed()))
            .perform(click())

        // 削除を戻すIntentの確認
        assertThat(activityRule.activityResult.resultCode).isEqualTo(MainActivity.RESULT_CODE_DELETE)
        assertThat(activityRule.activityResult.resultData).isNotNull()
        val data = activityRule.activityResult.resultData.getSerializableExtra(LogItemActivity.EXTRA_KEY_DATA)
        assertThat(data).isNotNull()
        assertThat(data is StepCountLog).isTrue()
        val expectItem = StepCountLog("2019/06/22", 456, LEVEL.BAD, WEATHER.HOT)
        assertThat(data).isEqualToComparingFieldByField(expectItem)
    }

なお、このクラスのテストは、Robolectricでも動作させるように書くことが出来ます。
Githubにpushしてありますので、参考にしたい方はご参照ください。

3. validationのテスト

logEditValidationのテストも書いておきましょう。といってもLogInputFragmentのより項目は減りますが。

LogEditFragmentTest.kt
class LogEditFragmentTest {

    @Test
    fun validation_error_emptyCount() {

        val result = logEditValidation("")
        assertThat(result).isEqualTo(R.string.error_validation_empty_count)
    }
}

まとめ

Roomを使ってデータを永続化する方法を学びました。
同時に、Repositoryクラスという概念で、ViewModelとデータの橋渡しをする設計について学びました。
ついでに(そうとしか言いようがないw)coroutineの書き方を学びました。

簡単なアプリなら、ここまでをテンプレートとして改変していくことで、ある程度の物が作れちゃう気がしますね(自画自賛)。

ここまでの状態のプロジェクトをGithubにpushしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_05

予告

投稿を、SNSに共有してみようと思います。
Facebookはプライバシーの観点からかなり難しくなっているので、まずはTwitter、その次はInstagramかな?
Instagramに投稿するには画像が必要ですが、他のアプリで見かけた手法をパクって参考にやってみようかなと思います。

あとは、これは更にその次に回るかも知れませんが、Databaseを実際に内臓ストレージに作っては消し、とやっているテストが気になるので、DIの導入を含めちょっと検討したいですね。

参考ページなど

15
19
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
15
19