nashcft's blog

時々何か書く。

InstantTaskExecutorRule がやってること

社で何これって聞かれてした説明を見えるところに文章で残しておこうと思ったもの。

👀

使い方

class MyTest {

  @get:Rule
  val instantTaskExecutorRule = InstantTaskExecutorRule()

  fun `test your component using AAC library`() {
    // LiveData とか Room とかが絡むテスト
  }
}

予備知識: @Rule, TestRule, TestWatcher について

InstantTaskExecutorRule の中身

小さいクラスなので実際のコードは上記の Code Search 等で見てもらうとして、やってることは以下のとおり:

  • テスト実行前に ArchTaskExecutordelegate を設定する
    • 設定した delegateisMainThread は常に true を返し、 executeOnDiskIOpostToMainThread は与えられた Runnable をそのまま実行する
  • テスト終了時に ArchTaskExecutor に設定した delegate を破棄する

ArchTaskExecutor

https://cs.android.com/androidx/platform/frameworks/support/+/1b2b8e2a1dbb4f524af976c8b3c254d6dee974f9:arch/core/core-runtime/src/main/java/androidx/arch/core/executor/ArchTaskExecutor.java

ArchTaskExecutor は、非同期タスクの実行を担う singleton object で、 LiveDataLifecycleRegistry, RoomDatabase で使われている。Main thread か IO thread でタスクを実行するためのAPI (postToMainThread, executeOnDiskIO) を持ち、またそれらでタスクを実行する Executor を取り出すこともできる。

ArchTaskExecutor にはもう一つ、今実行されている thread が main thread かを判定する isMainThread という API がある。これは main thread 上での実行を要求している API 内部で assertion のために使われ、例えば LiveData では observe するときや setValue の中で呼ばれている。

タスクの実行部分は delegate されており、 InstantTaskExecutorRule が行っているように setDelegate を使って外から設定することができる。ユーザが delegate を与えない場合は DefaultTaskExecutor が使用される。

https://cs.android.com/androidx/platform/frameworks/support/+/1b2b8e2a1dbb4f524af976c8b3c254d6dee974f9:arch/core/core-runtime/src/main/java/androidx/arch/core/executor/DefaultTaskExecutor.java

DefaultTaskExecutorpostToMainThread では、main looper に対する Handler を使ってタスクを main thread に post している。 isMainThread は、 main looper に紐ついてる thread と current thread を比較している。

JUnit local test と main looper

そもそもなんで使う必要があるのかというと、Android アプリとしてコードを実行する時と local test を実行する時の環境の差が関係している。Android アプリとして実行される際は、大雑把に説明すると、アプリのプロセスが起動した後まず ActivityThread で main looper の準備と実行が行われ*1、それから諸々の準備ができた後に我々の書いたアプリケーションコードが動く。しかし local test は JUnit test として実行されるので、Android アプリに関わるセットアップの類は実行されない。これがどう関係するかというと、local test 実行時には main looper のセットアップが行われず Looper#getMainLoopernull のままとなるので、それに依存する Handler や、 main looper をもとに "Android アプリにおける main thread" を判定する処理が機能しなくなってしまう。

class MyTest {

  fun test() {
    val mainLooper = Looper.getMainLooper()  // null

    val livedata = MutableLiveData<Int>()
    livedata.observeForever { println("onChanged") }
    // java.lang.NullPointerException
    //     at androidx.arch.core.executor.DefaultTaskExecutor.isMainThread
    //     at androidx.arch.core.executor.ArchTaskExecutor.isMainThread
    //     at androidx.lifecycle.LiveData.assertMainThread
    //     at androidx.lifecycle.LiveData.observeForever
  }
}

この問題に対しては 1) main looper をテスト実行前に用意する、もしくはあるように偽装する、2) LooperHandler に触れない形に変える、という解決方法を考えることができ、 2) を実現する手段として InstantTaskExecutorRule が用意されている、ということになる。

Robolectric による解決と比較

ところで Robolectric を使っても main looper の問題を解決することは可能である。Robolectric はテスト実行時に Android framework のコードを実行したり APIを偽装してくれたりする。つまり上で挙げた 1) のアプローチを取ることになる。今回のケースでは、テストケース実行時には Looper に main looper が準備された状態になっているため、 ArchTaskExecutorDefaultTaskExecutor を使っても NPE でクラッシュすることはなくなる。

@RunWith(AndroidJUnit4::class)
class MyTestWithRobolectric {

  fun `test with robolectric`() {
    val mainLooper = Looper.getMainLooper()  // non-null value

    val livedata = MutableLiveData<Int>()
    livedata.observeForever { println("onChanged") } // observe できる
  }
}

ただし Robolectric による Android framework 部分のセットアップには少なくとも数秒以上の時間がかかるので、「ユニットテスト」として評価した場合は有意に遅くなる *2。あと、非同期処理の待ち合わせについて何かやってくれるということはないので、例えば postValue している箇所がある場合は特別な考慮が必要になる。

@RunWith(AndroidJUnit4::class)
class MyTestWithRobolectric {

  fun `test with robolectric`() {
    val livedata = MutableLiveData<Int>()
    var count = 0
    
    livedata.observeForever {
      println("onChanged: $it")
      count++
    }

    livedata.value = 1  // テスト実行中に observer に通知が届く = 処理される
    livedata.postValue(2)  // 通知が届かない

    assertThat(count).isEquelTo(2) // Expected:2, Actual:1
  }
}

InstantTaskExecutorRuleArchTaskExecutordelegate を差し替えるだけなので非同期タスク処理の関わる部分がここのみの場合のみ有効だが、その分テストの実行時間への影響は小さい。また ArchTaskExecutor を通して行われる非同期処理もただ同期的に実行するようになるので、待ち合わせについて考慮する必要がなくなる。

class MyTestWithInstantTaskExecutorRule {

  @get:Rule
  val instantTaskExecutorRule = InstantTaskExecutorRule()

  fun `test with InstantTaskExecutorRule`() {
    val livedata = MutableLiveData<Int>()
    var count = 0
    
    livedata.observeForever {
      println("onChanged: $it")
      count++
    }

    livedata.value = 1
    // ArchTaskExecutor#postToMainThead の振る舞いが同期化されるので、
    // postValue もテスト実行時に observer に通知が届くようになる
    livedata.postValue(2)

    assertThat(count).isEquelTo(2) // passed
  }
}

使い分けとしては, LiveData や Room しか関わらないような local test については InstantTaskExecutorRule だけ使って、他にも Android framework への依存があるようなコンポーネントに対する local test では Robolectric も使う、みたいな感じで良いと思う。

まとめ

  • AACコンポーネントのいくつかはスレッド確認や非同期実行を行う際に ArchTaskExecutor を使用している
  • ArchTaskExecutor は振る舞いの実体を delegate しており、デフォルトでは DefaultTaskExecutor を使用している
  • DefaultTaskExecutor は main thread の確認に Looper#getMainLooper を参照している
  • Local test は Android framework に関わるセットアップを行ってくれないため、 main looper の準備も行われない
  • InstantTaskExecutorRuleArchTaskExecutordelegate を差し替えて、実行 thread の確認を何も見ずに答えたり非同期実行を担う箇所を同期的に処理したりするように挙動を変更することで、local test でも AAC 関連の非同期処理や main thread 制約の関わる部分をよしなに動くようにしてくれる

*1:この辺は『Android を支える技術 <I>』の 1.3.2 あたりを読むと良い

*2:Robolectric が「高速」と謳われる文脈は実機や emulator を用いたデバイステストとの比較で、apk をビルドしてデバイスにインストールして実行するのと比べたら当然高速