自作のViewGroupをRobolectricでテストしつつ開発する
2020/12/02追記: Activityじゃなくてfragment-testingを使いましょうという感じの記事をかきました
追記ここまで
ViewGroup
のonLayout()
が呼ばれた後でいろいろやるクラスを作ることになったので、ユニットテストを書きながら実装を進めることにした。
onLayout()
を呼んでもらうにはViewGroup
をActivity
にアタッチ(setConentView
)しなければならない。
ActivityScenario.launch(MainActivity::class.java).onActivity { activity -> val sut = TargetViewGroup(activity) it.setContentView(sut) }
とりあえずテストする分には適当なActivity
にアタッチすればいいが、MainActivity
にいろんな実装が入ったりするとテストの準備が大変なことになるのでテスト用のActivity
を用意しておいた方がいい。テスト用のActivity
もAndroidManifest.xml
に登録しておかなければならないので、テスト用の共通モジュールを作ってmainソースの中に入れておくと便利になる。Theme込みのテストがしたければAppCompatActivity
を継承し、Styleを定義して適用しておく。これが必要なければ普通のActivityでよい。
あとは順番にテストを書いていけばいいのだが、Robolectric 4.3で入った@LooperMode
というやつを使うことで、例えば1フレームの中でaddView()
を複数回呼んだ時の挙動を本来のものにより近づけることができる。それ以前のRobolectricではaddView()
を呼ばれたらその都度(requestLayout
を経由して) onMeasure -> onLayoutが呼ばれていた。
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でクリックできない件
クローンしてエミュレータなどで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でデータを管理することにした
前提
- サーバーからとってきたデータを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のSupportSQLiteOpenHelper
やSupportSQLiteDatabase
クラスを使って行う。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:これのほかにもテーブル名を列挙したりする必要がありますがお好みの方法でどうぞ。