だいぶ空いてしまいました。
テストの実装の方でかなり手こずっていました
Robolectricは、まだまだ難しい・・・(というかcoroutineがなかなか・・・)
前回の続きです。
今回の目標
- Roomでデータを永続化する
- Repositoryクラスの考え方に慣れる
- coroutineでの非同期処理の書き方を知る
データを永続化
拙記事を元にRoom用のクラスを準備していきます。
Roomやデータベースについての解説はそちらの記事に任せて、ここでは書きません。
(1) Databaseの準備
まず、dependenciesを更新します。
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としていきます。
テーブル名やカラム名は任意ですが紛らわしくないようにしましょう。
@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があればひとまずは事足りそうでしょうか。
@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の実装
@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を介してデータベースからデータを持ってきたり入れたりするのが仕事になりますが、実装するのは単純なラッパー関数がほとんどです。
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
クラスは次のようになります。
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というのを指定すれば良いのです。
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(log: StepCountLog)
これだけ!簡単ですね。
早速連続でデータを登録してみようとしましょう。
クラッシュせず、登録済のデータの情報が置き換わったのが分かります。
でも・・・よく考えれば、同じ日付のデータは「編集画面」でやるのが正しいUIな気がします。
ということで、次は、リストの既存データをタップして、データを編集して更新出来るようにします。
なお、親切に作るなら、追加画面では、登録前に同じ日付のデータがあるかをやっぱりチェックして、あれば「上書きしますか?」みたいに聞くのが良いでしょうね。
まあそもそも、この辺のUIは、将来的にカレンダー式表示にするとなるとまた大きく変わるので、あまり拘っても仕方の無いところですが。
(このシリーズでは、圧倒的に出番の多いRecyclerView(List)の使い方を知ってもらうのも主な目的ですので、リスト表示のままにしています)
データの編集に対応する
(1) 編集画面
行をタップして編集画面を出し、データの保存や削除が出来るようにしていきます。
1. 行のタップに反応して編集画面を出す
まず、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
で行います。
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
に、このリスナーを実装させます。
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のままで、新規登録用と編集時用の表示制御を入れるのでも構いませんが、コードをスッキリさせたいので別に作ります。
できあがりはこんな感じ。
`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に返します。
実装サンプルはこうなります。
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
}
levelFromRadioId
とweatherFromSpinner
は、LogInputFragment
にあったものを、Utils.ktに移動してグローバル関数にしました。
このFragmentをどこで使うかというと、LogItemActivityです。
Intentにデータがあるかどうかで、新規登録なのか更新なのか判断して、セットするFragmentを切り分けます。
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データを付けてあげましょう。
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
に必要な変数、関数を追加します。
private val _deleteLog = MutableLiveData<StepCountLog>()
val deleteLog = _deleteLog as LiveData<StepCountLog>
@UiThread
fun deleteLog(data: StepCountLog) {
_deleteLog.value = data
}
起動して、リストの行をタップしてみます。
既存のデータがちゃんと入っていますか?ラジオボタンや、天気のスピナーはちゃんと選択されていますか?
変更して、登録ボタンを押してみましょう。メイン画面に戻ると、ちゃんと更新されているかと思います。
同様に、削除でリストから消え・・・ませんね。
理由は分かりますでしょうか?
viewModel.deleteLog.observe(this, Observer{
val dataIntent = Intent()
dataIntent.putExtra(EXTRA_KEY_DATA, it)
setResult(MainActivity.RESULT_CODE_DELETE, dataIntent)
finish()
})
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
で返した場合に対して、MainActivity
がonActivityResult
で処理をしていないからですね。ここに追加して上げましょう。
そうなると、onNewStepCountLog
という名前がそぐわなくなるので、適当に変えます。右クリック-[Refactor]-[Rename]で呼び出し元も変えてくれるので便利です。
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
関数を追加します。
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
を実装しましょう。
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
クラスは、汎用性を持たせるため、以下のような実装にしました。
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とかでチェックしてわざとクラッシュさせるパターンもありますが、仮に実装が漏れていても落ちないでエラーログが出るだけにしています。
この辺りは設計思想や、開発に関わる人数、そのレベル感によっていろんなアプローチがあると思います。
拘り始めると根が深い部分で、いろんな提言をしている人がいますので、時間があれば調べてみると良いかと思います。
ということで、MainActivity
にConfirmEventListener
を実装させます。
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の変更
前準備として、多分次の依存ライブラリを追加しておかないとテストが実行できないかと思います。
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)
をクラス宣言に追加します。
@RunWith(AndroidJUnit4::class)
class MainViewModelTest {
....
@Before
fun setUp() {
val appContext = ApplicationProvider.getApplicationContext<Application>()
viewModel = MainViewModel(appContext)
}
....
3. 各テストの修正と追加
init
テストは、LiveDataの更新待ちをする必要が生じるため、次のように書き換えます。
@Test
fun init() {
assertThat(viewModel.repository)
.isNotNull()
assertThat(viewModel.stepCountList)
.isNotNull()
viewModel.stepCountList.observeForTesting {
assertThat(viewModel.stepCountList.value)
.isEmpty()
}
}
observeForTesting
という関数は、下記のような拡張関数です。
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
テストは、次のようになります。
@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
クラスは次のような物です。
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
というのは、その処理が完了するか、指定時間が経過するまで待ちたい、という用途で使えます。
上記の実装は、指定の回数、監視しているLiveData
のonChanged
が呼ばれると終了する、という動作となっています。
addStepCount
テストでは、2回データを投入しているので、val listObserver = TestObserver<List<StepCountLog>>(2)
と2回変更があるまで待つことになります。
削除関数も増えているので、それのテストも追加します。
@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
クラスに日付をキーにデータを取得するクエリーメソッドを追加します。
@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
クラス、リポジトリクラスを持つようにします。
@RunWith(AndroidJUnit4::class)
class LogRepositoryTest {
private lateinit var database: LogRoomDatabase
private lateinit var logDao: LogDao
private lateinit var repository: LogRepository
}
データベースの作成はちょっと特殊です。
@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
のテストを書いてみましょう。
@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
でデータを取り出して期待値通りかチェックしています。
同じように他の関数もテストを書いていきます。
@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
の中が実行されていません。
クラスの先頭に、以下を追加して下さい。
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
(2) メイン画面のテスト
こちらも、Roomのスレッド処理が上手くいかないようでRobolectricだとエラーになります。なので、andoridTestの方に実装していきます。
(勢い込んでRobolectric導入したけど、結局andoridTestもまだまだ必要で、混在しているという分かりづらい状況になっちゃいました。
時期尚早でしたかね。残念無念。)
とりあえず、各テストの最初と最後にデータをクリアするように、こちらもsetUp
とtearDown
関数を書いておきます。
今回は、リポジトリクラスからdeleteAll
するのではなくて、アプリケーション全体で持っているデータベースをまるっと削除します。
@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
は適当な場所で宣言しておきましょう。
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
は、複数行のリスト表示の確認テストですが、テスト名が分かりにくかったので変えます。
そして、日付の降順に表示順が変わっているのでそこも合わせます。
@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. 既存データの行をタップで編集画面が起動するテスト
既存データの行をタップしたときに、編集画面が起動しているかのチェックをテストにします。
@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
テストの名前を変えておきます(変更と削除のテストを追加するため)。
後はそのままです。
@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. 編集画面の戻り(更新)テスト
これはさっきの「追加の戻り」テストコードとほぼ同じですが、最初にデータを投入しておくところが違います。
@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を削除用の物とし、表示が変わっていることをチェックします。
@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()
を使います。
これまで書いてきたテストでやってきた内容で書けるので、是非一度自力で書いてみてください。
サンプルはこちらからどうぞ。
@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) 編集画面のテスト
最後に編集画面のテストです。
この画面では、新たにIntent
にExtraData
があればLogEditFragment
がセットされるようになりました。
Fragmentのテストは単体で書く方法もあるようなのですが、ここはActivityのテストの中でやってしまうことにします。
Fragmentでやることがそれぞれに複雑になってくるような場合には、FragmentScenaio
というのを使ってテストをした方が良い場合もあるでしょう。詳しくはこちらを参考にしてください。
※私も使ってみようとしたのですが、どうしたわけか依存関係が解決できないエラーが出て解消できなかったので、使用を断念しています。。。
GralePluginのバージョンなどが関係しているんでしょうかね…
1. 編集画面の起動状態のテスト
LogEditFragment
なのかどうかは、表示内容で判断していきます。
/**
* 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起動コード、初期値などが微妙に違いますのでその辺に注意してください。
サンプルはこちらからどうぞ。
/**
* 編集画面:ラジオボタン[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
の内容が正しいかのチェックとします。
/**
* 編集画面:削除ボタン押下のテスト:カウント未入力エラー
*/
@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
のより項目は減りますが。
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の導入を含めちょっと検討したいですね。
参考ページなど
-
Google CodeLabs Android Room with a View - Kotlinを日本語で概要解説
(https://qiita.com/kasa_le/items/ad7bbaef4fa1b5abbbca) -
LiveDataのUnitTest
https://medium.com/@star_zero/livedata%E3%81%AEunittest-2b295d2818c1 -
CoroutinesのUnit Testメモ
https://medium.com/@star_zero/coroutines%E3%81%AEunit-test%E3%83%A1%E3%83%A2-b17806a1d252 -
liveData{} の UnitTest を書いてみた
https://note.mu/numero_dev/n/n30e160e53d48