fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

自作のViewGroupをRobolectricでテストしつつ開発する

2020/12/02追記: Activityじゃなくてfragment-testingを使いましょうという感じの記事をかきました

akihito104.hatenablog.com

追記ここまで


ViewGrouponLayout()が呼ばれた後でいろいろやるクラスを作ることになったので、ユニットテストを書きながら実装を進めることにした。

onLayout()を呼んでもらうにはViewGroupActivityにアタッチ(setConentView)しなければならない。

ActivityScenario.launch(MainActivity::class.java).onActivity { activity ->
  val sut = TargetViewGroup(activity)
  it.setContentView(sut)
}

とりあえずテストする分には適当なActivityにアタッチすればいいが、MainActivityにいろんな実装が入ったりするとテストの準備が大変なことになるのでテスト用のActivityを用意しておいた方がいい。テスト用のActivityAndroidManifest.xmlに登録しておかなければならないので、テスト用の共通モジュールを作ってmainソースの中に入れておくと便利になる。Theme込みのテストがしたければAppCompatActivityを継承し、Styleを定義して適用しておく。これが必要なければ普通のActivityでよい。

あとは順番にテストを書いていけばいいのだが、Robolectric 4.3で入った@LooperModeというやつを使うことで、例えば1フレームの中でaddView()を複数回呼んだ時の挙動を本来のものにより近づけることができる。それ以前のRobolectricではaddView()を呼ばれたらその都度(requestLayoutを経由して) onMeasure -> onLayoutが呼ばれていた。

robolectric.org

LooperModeは引数を一つとるアノテーションで、テストクラスやテストメソッドにつける。Mode.PAUSEDを指定するとLooperの挙動が本来の動きに近くなる。例えば次のような感じ。

    @Test
    @LooperMode(LooperMode.Mode.PAUSED)
    fun test() {
        ActivityScenario.launch(ContainerActivity::class.java)
            .onActivity {
                val sut = TargetView(it)
                val adapter = mockk<TargetView.Adapter>().apply {
                    every { onMeasure() } just runs
                    every { onLayout() } just runs
                }
                sut.adapter = adapter
                it.setContentView(sut)
                shadowOf(getMainLooper()).idle()

                sut.addView(TextView(it))
                sut.addView(TextView(it))
                shadowOf(getMainLooper()).idle()

                assertThat(sut.childCount).isEqualTo(2)
                verify(exactly = 2) { adapter.onMeasure() }
                verify(exactly = 2) { adapter.onLayout() }
            }
    }

ここでのポイントは shadowOf(getMainLooper()).idle() で、このテストケースではこれを呼ぶごとにViewの更新ライフサイクルが進むことになる。LooperModeアノテーションを外すか、渡すモードをLEGACYに変えると、下2つのverifyブロックはexactry = 3 としてやることでテストが通るようになる。

ちなみにこのモードでテストが失敗すると、java.lang.Exception: Main looper has queued unexecuted runnables. This might be the cause of the test failure. You might need a shadowOf(getMainLooper()).idle() callのようなメッセージがでることがある。Activityがいる状態だとどうしても出てしまうので、本当にidle()を呼んで解決する状況かどうか見極めた方がいい。

https://gist.github.com/akihito104/d9076720e14dad439461ab8c03a6279d

rotationを指定したViewをEspressoでクリックできない件

github.com

クローンしてエミュレータなどでandroidTestを実行してみてください。

なぜ?

EspressoのViewActions.click()は内部でGeneralClickActionを使ってクリックアクションを作っている。GeneralClickActionを生成する際にいくつか引数を渡す必要があるのだが、この中でクリックする位置を決めているのは2個目の引数であるCoordinatesProviderクラスのオブジェクトである。click()ではその実装であるGeneralLocation enumクラスのGeneralLocation.VISIBLE_CENTERが渡されている。

この実装の中ではView.getLocationOnScreen()で得た対象のViewの左上の座標に、Viewの幅と高さの半分の値をそれぞれXとY座標に足して返している。ここで問題なのは、View.getLocationOnScreen()で取得できる座標はもともとのViewの左上の座標に回転変形のマッピング計算を施したものであること。例えば、もともとのViewの中心を軸にして右に30度回転しているとすれば、返ってくる値も右上方向にずれることになる。そこを起点にして元々のViewの幅と高さの半分をそれぞれXとYに足しても、回転後のViewの中心にはならない。正方形のViewであれば、45度以上回転するとViewの外側に出てしまう。

サンプル実装のリポジトリではView.getGlobalVisibleRect()を使って、回転したViewを内包するRectを取得してその中心座標を返すCoordinatesProviderを実装し、GeneralClickActionに渡している。これによって一応クリックできるようになっている。

UIテストでDBに入れるデータをテストアプリのassetsとして管理する

モチベーション

  • サーバーとは独立した環境でテストを行えるようにしたかった
  • 必要なデータの種類が結構多いのでテストコードの中でデータを作ってDAOからDBにInsertしまくるというのが面倒くさそうだった
    • OkHttpのMockサーバーから返すレスポンスをテストコードの中にゴリゴリ書くのも同じ理由で面倒くさいと思われる
  • 必要なデータが入っているDBをテスト時にコピーすればいいという話もあるが、どんなデータが入っているか簡単にわかって、かつ簡単にいじったりできたらいいかなと思ってCSVでデータを管理することにした
    • TSVとかYamlとかでもいいかもしれないが、SQLiteにはテーブルの中身をCSV形式でダンプする機能があるので手っ取り早い。

前提

  • サーバーからとってきたデータをDBにキャッシュし、DBにあるデータを信頼できる唯一のソースとする。
  • DB: Room 2.1.0
  • DI: Koin 2.0
  • UIテスト: Espresso 3.1.0

どうやって?

まずはDBからデータを吸い出す。sqlite3はDBファイルからテーブルのデータをCSVでダンプさせることができる。INSERT INTO...の形式でも吐き出せるので読みにくくならなさそうならこれでもいいかもしれない。*1

$ sqlite3 db_file -cmd ".headers on" \ # カラム名を最初の行に追加する
  ".mode csv" \
  ".output table.csv" \
  "SELECT * FROM table"

src/androidTestの直下にassetsディレクトリを作って、ここに上のコマンドで出力したCSVファイルを置くと、それがそのままテストアプリのAPKファイルの中に入る。次のような感じでテストアプリのContextからアクセスできるようになる。これを使ってテストケースの最初にデータが読み込まれるようにする。

val testAssets = InstrumentationRegistry.getInstrumentation().context.assets

モジュール分割のやり方によっては、UIテストを実装するモジュールが、Roomやそれを使って実装しているDaoやEntityのクラスとかを知らなかったりする。依存させてもいいかもしれないが、なんとなくテスト対象へのクラスに依存しないようにしておきたかったので、INSERT INTO table_name VALUES(...)を使ってCSVの行をSQLに直接流し込むことにした。

SQLiteの操作はandroidxのSupportSQLiteOpenHelperSupportSQLiteDatabaseクラスを使って行う。RoomDatabaseからSupportSQLiteOpenHelperを取得できるので、DIか何かを使ってテストクラスに渡せるようにしておく。defer_foreign_key PRAGMAをtrueにしてから流し込むとはかどる。

openHelper.writableDatabase.run {
  execSQL("PRAGMA defer_foreign_keys = true;")
  beginTransaction()
  try {
    tableFiles.forEach { file ->
      val tableName = file.nameWithoutExtension
      testAssets.open(file.path).reader().useLines { lines ->
        lines.filter { /* スキップしたい行を取り除いたりとか */ }
            .map { /* データのサニタイズとか */ }
            .forEach { line -> 
              execSQL("INSERT INTO `$tableName` VALUES($line);")
            }
      }
    }
    setTransactionSuccessful() // 忘れずに呼ぼうね(1敗)
  } finally {
    endTransaction()
    execSQL("PRAGMA defer_foreign_keys = false;")
  }
}

DBのデータだけでなく、端末で行うテストで使うリソースファイルなら大体のことに応用できるんじゃないかと思うのでうまくいったら教えてください。

*1:これのほかにもテーブル名を列挙したりする必要がありますがお好みの方法でどうぞ。