DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

MOV Android版に対する「コード改善+テスト導入」の取り組みの紹介

こんにちは。SWETの瀬戸(@seto_hi)です。

2019年7月下旬からSWETにジョインし、テストが書きにくい設計を改善して自動テストの導入をサポートする取り組みを行っています。 その取り組みの一環として、MOV Androidアプリの設計の改善を進めてきました。

本記事では、2か月間の中で改善した4つの事例について紹介したいと思います。

MOVのAndroidアプリについて

DeNAでは MOV というタクシーの配車サービスを運営しています。
今回リファクタリングを行ったのは、アプリのユーザーが配車依頼から支払いまでを行う、アプリの中心となる画面です。

画面構成

画面の構造は下記のようになっています。

f:id:swet-blog:20190924175038p:plain

常時表示されるActivityと地図Fragmentはシンプルですが、OverlayFragmentの構造がとても複雑な作りとなっています。 OverlayFragmentは20種類以上の状態があり、その状態に従って10種類以上のFragmentが差し替わるようになっています。
それだけでなく、OverlayFragmentの変更を受けてActivityと地図Fragmentの状態も変更されるので、状態の伝搬や管理が複雑になっています。

実装上の問題点と解決策

画面構成の複雑さだけでなく、MOVのユーザーアプリには様々な歴史的経緯があり内部実装は更に複雑になっていました。 特に、ActivityとFragmentは見事なFatActivityとFatFragmentとして育っており、修正コストの増加やテストコードの書きづらさといった課題につながっていました。

原因を分解し、それぞれについて解決策を考えました。

課題1:BaseFragmentと多数のopenメソッド

BaseFragmentに空実装のopenメソッドが大量に定義され、各Fragmentはそのメソッドを必要に応じてoverrideしており見通しが悪くなっていました。
多くのメソッドはイベントをトリガーとして呼ばれるものだったため、ViewModelにイベント用のLiveDataを用意し、各FragmentでobserveすることによりBaseFragmentへの依存を減らしました。

課題2:Activity、地図Fragment、OverlayFragment間の不必要な依存

地図の操作イベントはCallback経由で一旦Activityに集約され、再度地図FragmentやOverlayFragmentに分配されていました。
Activityに処理を集約する必要は全くなかったので、ViewModelのLiveData経由で地図Fragmentに処理をさせるように変更しました。
OverlayFragmentの画面遷移を知らせるイベントもCallback経由で行っていたのですが、NavController.OnDestinationChangedListenerを使い、依存と実装量を減らすことができました。

これらの修正によってActivityがFragmentのインスタンスを持つ必要がなくなり、今後不必要な依存を作ってしまう可能性が減りました。

課題3:APIコールとCallbacksの実装がActivity/Fragmentにべた書きされている

APIコールとCallbacksの実装がActivity/Fragmentに書かれていたため、Activity/Fragmentの行数が増えるだけでなく、Callback内の処理のテストを書くことが難しくなっていました。

これを解消するため、Googleの提唱する Recommended app architecture に従い、設計を変更しました。
ViewModelでAPIコールをし、Coroutine化したことでcallbackも少ない行数で実装ができています。

f:id:swet-blog:20190924164641p:plain

ActivityやFragmentが状態を持っていた設計をRecommended app architectureに適合するにあたり、今回は3段階のステップを踏みました。

  1. Repositoryを作成することでViewModelへの移植をしやすくする
  2. Coroutine化でCallback処理の整理をする
  3. 状態をLiveData化し、ViewModelへ移植する

依存を減らしつつ移植することにより、安全な設計変更を行うことができました。

改善後のプロダクトコードとテストコードは以下のようになりました。

サンプルコード

CarRequestRepositoryにはCarRequestを返すgetCarRequest()というメソッドがあります。
(ここでは簡易的な例として、localにデータをキャッシュしないものとします)

class CarRequestRepository(
  private val remoteSource: CarRequestRemoteDataSource
) {
  suspend fun getCarRequest(): CarRequest = 
      remoteSource.getCarRequest()
}

ViewModelではこれを読み込み、LiveDataに保存します。 必要であればActivityやFragmentでLiveDataをObserveして値を使うことができます。

class CarRequestViewModel(
  private val repository: CarRequestRespository
): ViewModel {

  private val _carRequest = MutableLiveData<CarRequest>()
  val carRequest: LiveData<CarRequest> = _carRequest
  
  fun loadCarRequest() {
    viewModelScope.launch {
      _carRequest.value = repository.getCarRequest()
    }
  }
}

テスト

このような設計だとテストも書きやすくなります。
CarRequestViewModelのコンストラクタ引数としてCarRequestRespositoryを渡しているため(コンストラクタインジェクション)、モックオブジェクトに差し替えることが容易になります。

CarRequestViewModel#loadCarRequest呼んだ際、取得結果をobserverで検知できることを確認するテストは以下のようになります。

class CarRequestViewModelTest {
  
  @Test
  fun testLoadCarRequest_success() {
    val mockRepository = mockk<CarRequestRepository>()
    val target = CarRequestViewModel(mockRepository)
    val result = CarRequest()

    // coEveryはsuspend fun向けのevery
    // mockRepository.getCarRequest()が呼ばれたらresultを返すように設定する
    coEvery { mockRepository.getCarRequest() } returns result

    // ViewModelのcarRequestが変更されたことを確認したいのでobserverをmockする
    val mockObserver = spyk<Observer<CarRequest>>()
    target.carRequest.observeForever(mockObserver)
    // テスト対象のメソッドの呼び出し
    target.loadCarRequest()

    // mockObserverがmockRepository.getCarRequest()の結果で呼ばれたことを確認する
    verify(exactly = 1) {
      mockObserver.onChanged(result)
    }
  }
}

課題4:DialogやDrawerMenuのCallback実装もActivity/Fragmentで行っている

こちらも課題3と同様にCallbacksの実装がActivity/Fragmentに書かれていたため、Activity/Fragmentの行数が増えるだけでなく、Callback内の処理のテストを書くことが難しくなっていました。

こちらはEnumを利用することで課題を解決しています。

サンプルコード

まず、メニューの各項目をEnumとして定義します。
MOVのアプリではメニューの選択時に新しいActivityを開くようになっているため、Intentの生成メソッドも定義しました。

enum class DrawerMenuItem(
  @IdRes private val menuId: Int
) {
  ACCOUNT(R.id.menu_account) {
    override fun createIntent(context: Context): Intent =
        AccountActivity.createIntent(context)
    },
...

  abstract fun createIntent(context: Context): Intent
}

取り回しを便利にするため、idを元にEnumの要素を取得できるメソッドを定義します。

  companion object {
    fun valueOf(@IdRes id: Int): DrawerMenuItem =
        values().firstOrNull { it.menuId == id }
            ?: throw IllegalArgumentException()
  }

こういった実装をすることにより、Activity側では以下のように記述するだけで各メニューの選択時の処理ができるようになります。
将来的にメニュー項目が増えても、Activity側に修正を入れる必要はありません。

  navigation.setNavigationItemSelectedListener { menuItem ->
    val intent = DrawerMenuItem.valueOf(menuItem.itemId)
           .createIntent(this)
    startActivity(intent)
    false
  }

ちなみに、このようにEnumを使った手法はActionMenuやActivityResultにも応用できます。

テスト

enum化することによりテストも書きやすくなります。
DrawerMenuItem.ACCOUNT.createIntent のテストは以下のようになります。

  @Test
  fun createIntent_account() {
    val intent = DrawerMenuItem.ACCOUNT.createIntent(context)

    assert(intent.component).isNotNull()
    assert(intent.component!!.className).isEqualTo(AccountActivity::class.java.name)
  }

最後に

上記のような修正によって、アプリの設計は改善の方向に進み、テストコードも書きやすい状態に変化していきました。 このようなアプリの設計改善に限らず、SWETチームでは引き続き自動テストを導入するための取り組みを進めていきます。

このような取り組みに興味を持たれた方は、下記URLからのご連絡をお待ちしております!

募集職種: SWET (Software Engineer in Test) / テスト自動化エンジニア(Android Test)