Androidでテスト駆動開発

自己紹介

Androidエンジニアの坂本です。

Android未経験で3月末に入社して約半年になります。

入社前は、完全未経験の状態からiOSの勉強を独学で1年ほどやった程度。

そこから初めてジモティーのインターンでAndriodをすることになり今に至るといった感じです。

課題

ジモティーでは既存のテストコードはあるものの、工数だったりスピード感の兼ね合いでテストコードが書けていないこともあります。

今回はテストコードのメリットを確認しながら、簡単な機能をテスト駆動開発してみます。

テストコードのメリット

  • 仕様書になる
    • テストコードを見ると何をしているのか分かる
  • 無駄な開発時間を短縮出来る
    • コード修正のたびにビルドしなくてもいい
      • ロジックの実装はテストコードで担保して、最後に挙動確認でビルドする
  • 設計を見直すきっかけになる
  • 心理的安心が生まれる
    • ここのロジックは間違いないという確信を思って開発が進められる
      • (自分のコードを疑わなくていいということではない)

テスト駆動開発でやってみる

同期的な処理

ViewModelを作成してメソッドだけ用意

class CountViewModel : ViewModel() {
    
    private val _totalCount: MutableLiveData<Int> = MutableLiveData()
    val totalCount: LiveData<Int> = _totalCount
    
    fun onClickPlusButton() {

    }
}

テストを作成

テスト実装

実装したいロジックをテストするコードを書く

  • ViewModelのメソッドを呼んだ時に、LiveDataが期待した値で更新されているかのテスト
@RunWith(AndroidJUnit4::class)
class CountViewModelTest {
    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    private lateinit var countViewModel: CountViewModel

    @Mock
    lateinit var mockTotalCountObserver: Observer<Int>

    @Before
    fun setUp() {
        countViewModel = CountViewModel()
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun onClickPlusButton_プラスボタンをクリックした時_渡された値の合計が通知できていること() {
        countViewModel.totalCount.observeForever(mockTotalCountObserver)

        countViewModel.onClickPlusButton(10, 20)

        verify(mockTotalCountObserver).onChanged(30)
    }
}

失敗することを確認する

正しい処理を実装

class CountViewModel : ViewModel() {
    
    private val _totalCount: MutableLiveData<Int> = MutableLiveData()
    val totalCount: LiveData<Int> = _totalCount
    
    fun onClickPlusButton() {
        _totalCount.value = firstNumber + secondNumber
    }
}

無駄な開発時間を短縮出来る

プロジェクトが大きくなってくると、1回ビルドするのに5分10分かかることもあると思います。

ロジックを変更する際に毎回ビルドしていると、ビルド待ちの時間も増えてきます。

テストの実行はビルドの時間に比べると短いので、テストでロジックが正しく実装されていることを確認してから最後にビルドで確認すると、ビルド回数が抑えられて効率良く開発できることにつながるかもしれません。

非同期の処理

次は非同期のテストを書いていきます。

サーバーから未読数を取得してLiveDataを更新するという想定でテスト書いてみます。

作成したテスト

  • fetchTotalUnreadCountUseCaseのモックを作成し、UnreadCount(1, 2)を返すようにします。
  • 作成したUseCaseを使ってViewModelをインスタンス化
  • メソッドを叩いた時にLiveDataが未読数の合計で更新されているかを検証します。
@RunWith(AndroidJUnit4::class)
class CountViewModelTest {
    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    private val dispatcher = TestCoroutineDispatcher()

    @Mock
    lateinit var mockTotalUnreadCountObserver: Observer<Int>

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        Dispatchers.setMain(dispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        dispatcher.cancel()
    }

    @Test
    fun 未読数の合計を取得して値を渡せていること() = dispatcher.runBlockingTest {
        val fetchTotalUnreadCountUseCase = mock<FetchTotalUnreadCountUseCase>()
        whenever(fetchTotalUnreadCountUseCase.invoke())
            .thenReturn(UnreadCount(1, 2))

        val countViewModel = CountViewModel(fetchTotalUnreadCountUseCase)
        countViewModel.fetchTotalUnreadCount()
        countViewModel.totalUnreadCount.observeForever(mockTotalUnreadCountObserver)
        val actual = countViewModel.totalUnreadCount.value
        assertEquals(3, actual)
    }
}

FetchTotalUnreadCountUseCaseは非同期想定なのでsuspendを付けておきます。

class FetchTotalUnreadCountUseCase {
    private val unreadCountRepository: UnreadCountRepository = UnreadCountRepositoryImpl()

    suspend fun invoke(): UnreadCount {
        return unreadCountRepository.fetchUnreadCount()
    }
}

失敗を確認する

  • テストコードでは未読数の合計3を期待して書く。
  • 実装側ではfromUserCountだけで更新するような実装にしてみた。

失敗を確認できました。

実際に実装する

    fun fetchTotalUnreadCount() {
        viewModelScope.launch {
            val unreadCount = fetchTotalUnreadCountUseCase.invoke()
            _totalUnreadCount.value = unreadCount.fromUserCount + unreadCount.fromSystemCount
        }
    }

テスト成功

設計を見直す

テストコードを書こうとすると、テストが書きづらかったりして改めて実装を見直す機会になったりする時があります。

実際に先ほど作成してテストコードを見てみると、ViewModelでは合計を取得して値を渡せばよさそうですが、

    @Test
    fun 未読数の合計を取得して値を渡せていること() = dispatcher.runBlockingTest {
    }

実装側では取得した未読数の合計を求める処理も書いてあります。

    fun fetchTotalUnreadCount() {
        viewModelScope.launch {
            val unreadCount = fetchTotalUnreadCountUseCase.invoke()
            _totalUnreadCount.value = unreadCount.fromUserCount + unreadCount.fromSystemCount
        }
    }

合計の処理はモデルに移すなどの選択肢もあるかもしれません。

data class UnreadCount(
    val fromUserCount: Int,
    val fromSystemCount: Int,
) {
    val totalCount = fromUserCount + fromSystemCount
}
    fun fetchTotalUnreadCount() {
        viewModelScope.launch {
            val unreadCount = fetchTotalUnreadCountUseCase.invoke()
            _totalUnreadCount.value = unreadCount.totalCount
        }
    }

サンプルコード

おわりに

今回はテストコードを実際に書いて、テスト駆動開発の流れとメリットを見ていきました。

毎回ビルドして確認する必要なかったり、設計の見直しができたり、良い面はかなりあると思いました。

ただ現状、テストコード書くことに慣れていないので時間がかかってしまうことの方が多いです。

全ての処理にテストを書けば良いというわけではないと思うので、テストを書く意味や目的を理解して、開発効率向上やバグの早期発見のためにうまく使っていけるようになりたいですね。