10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Androidでテスト駆動開発に挑戦してみた(旧Android × TDD)

Last updated at Posted at 2019-12-07

テストコード書いてますか?

「書きたいと思っているが、なかなか難しい」が正直な現状でしょうか。
筆者も何度もトライし、何度も挫折しています。
「この記事(Dagger2を使ったらUnitTestが楽になるか考えてみた)」も結構頑張って考えたのですが、UIの凄まじい変化のスピードに耐えられませんでした。

さて、どうやったら無理なく習慣化出来るのか。

答え : TDD × Checking

弊社内のモバイルチームで上記の悩みを相談した結果、「Checkingのテストコード」が良いのではないかという結論が出ました。

「Checking」とは「仕様、設計を確認するテストを重視する」。
テストコード検討と作成を極力軽くし、テストコードと同時にクラス設計も行う。

「境界値、上限下限、条件網羅、分岐網羅などカバレッジ100%目指す」という方向性の「Testing」とは、全く性質が異なります。

テストコードを素早く検討できるので、以下のTDDサイクルを負担なく回せます。

  1. まずクラス実装より先にテストコードを書く
  2. クラス側について、テストが成功する最低限の実装を書く
  3. テストが通ったら、リファクタリングしメンテナビリティ確保する

UIの変化のスピードにテストコードが耐えられなかったのでは? その問題はどうするのか?

UIのテストは諦めます。
その代わり、ビジネスロジック(モデル、ドメイン)に対してテストを書きます。
「UIからモデルを分離すれば、陳腐化も遅いだろう」という狙いです。

RobolectricもEspressoも使わず、JUnitのみ使います。
境界値や網羅性は優先度を落とし、仕様、設計の確認が出来る最低限のテストコードを書きます。

テストコード作成の負担が大きいと、徐々にテストコードを書かなくなってしまうので(経験済み)、極限まで負担を減らします。

TDD実演

モデルの分離→テストコード実装→モデル実装→テストコード実装...のループを実演していきます。

環境

  • JUnit4
  • Assertj

お題:通信中のローディング表示機能

例として、通信中のローディング表示機能からモデルを分離してテストコードを書いてみます。

要求事項
ローディング表示は、通信の開始と同時に行い、完了したら非表示にすること
複数の通信を同時に行う場合、最後の通信が完了したらローディングを非表示にすること

上記の要求であれば、「ローディングを表示する/非表示にする機能(UI側)」と、「実行中の通信の数を管理する機能(ロジック側)」に分けられます。
後者のモデルをTaskCounterと命名してクラス設計を進めます。

TaskCounter
カウントアップ(add)、カウントダウン(remove)のIFを持ち、
カウントが0になったら完了通知(complete)を出す。

通信の開始時にaddを呼び、完了時にremoveを呼ぶ。複数の通信を並行で行っても、簡単に「最後の通信が完了」した事をcomplete通知で検出できます。

蛇足:可能ならば、ビジネスロジック専用のモジュールプロジェクトを用意する

以下の記事の「domain_core」モジュールを用意すると良いかと思います。
「(マルチモジュール化したりビルド時間を短くするコツ)」

そうすると、テスト時のビルド範囲がビジネスロジックのみに限定できるので、一瞬でビルド&テストが出来て恩恵を最大限に享受できます。

完成後のテストコードは、こんな感じになります

テストコード
class TaskCounterTest {
    @Test
    fun initialCount() {
        TaskCounter().run {
            assertThat(count).isEqualTo(0)
        }
    }

    @Test
    fun add() {
        TaskCounter().run {
            add()
            assertThat(count).isEqualTo(1)
        }
    }

    @Test
    fun remove_when_count_is_1() {
        TaskCounter().run {
            add()
            remove()
            assertThat(count).isEqualTo(0)
        }
    }

    @Test
    fun remove_when_count_is_0() {
        TaskCounter().run {
            remove()
            assertThat(count).isEqualTo(0)
        }
    }

    @Test
    fun complete() {
        var testSuccess = false
        TaskCounter().run {
            complete = {
                testSuccess = true
            }
            add()
            remove()
            assertThat(testSuccess).isTrue()
        }
    }
}
モデル
class TaskCounter {
    var count: Int = 0
    var complete: () -> Unit = {}

    fun add() {
        count++
    }

    fun remove() {
        count = (count--).coerceAtLeast(0)
        if (count == 0) {
            complete()
        }
    }
}

では、テストコード作成の実演を始めます。

step 1 / 5 : モデルクラスを定義する

モデル
class TaskCounter {
}

これでおしまいです。中身は実装しません。
「テストコードを先に書く」が原則です。

step 2 / 5 : テストコード作成。まずは、モデルの初期値を考える

何から始めるかはモデルの内容にもよりますが、今回は最初に内部変数の初期値を考えます。
そう聞くとおそらく、「int型のcount変数で、初期値は0かな?」と頭に浮かぶと思います。
それをそのままテストに書きます。

テストコード
class TaskCounterTest {
    @Test
    fun initialCount() {
        TaskCounter().run {
            assertThat(count).isEqualTo(0)
        }
    }
}

テストを作成後、モデル側を実装します。
ポイントは**「テストが成功する最低限の実装のみ行う」**です。

モデル
class TaskCounter {
    var count: Int = 0
}

これでテストケースが1個、完成です。

step 3 / 5 : カウントアップ(add)を考える

addメソッドを呼ぶとcountが1になるのが期待値なので、それをテストコードを書きます。

テストコード
class TaskCounterTest {

    (...中略...)

    @Test
    fun add() {
        TaskCounter().run {
            add()
            assertThat(count).isEqualTo(1)
        }
    }
}

addメソッドのテストはこれで十分です。
理由は、「初期値0」のテストが成功したので「addしたらcount=1」となればインクリメントが正常に機能することが立証されるからです。

次に、先ほどと同じようにテストが成功する最低限の実装のみ行います。

モデル
class TaskCounter {

    (...中略...)

    fun add() {
        count++
    }
}

step 4 / 5 : カウントダウン(remove)を考える

まずはテストコードから。

テストコード
class TaskCounterTest {

    (...中略...)

    @Test
    fun remove_when_count_is_1() {
        TaskCounter().run {
            add()
            remove()
            assertThat(count).isEqualTo(0)
        }
    }

    @Test
    fun remove_when_count_is_0() {
        TaskCounter().run {
            remove()
            assertThat(count).isEqualTo(0)
        }
    }
}

addされたカウンタが、removeでデクリメントされている事が確認できました。
また、カウンタが負の値にならない事も確認できました。
続いて、モデル側です。

モデル
class TaskCounter {

    (...中略...)

    fun remove() {
        count = (count--).coerceAtLeast(0)
    }
}

着々と完成してきましたね。

step 5 / 5 : 完了通知(complete)を考える

例によって、まずはテストコード。

テストコード
class TaskCounterTest {

    (...中略...)

    @Test
    fun complete() {
        var testSuccess = false
        TaskCounter().run {
            complete = {
                testSuccess = true
            }
            add()
            remove()
            assertThat(testSuccess).isTrue()
        }
    }
}

カウンタが0になるとcompleteが呼ばれる事が確認できました。
続いてモデル側。

モデル
class TaskCounter {

    (...中略...)

    var complete: () -> Unit = {}

    (...中略...)

    fun remove() {
        count = (count--).coerceAtLeast(0)
        if (count == 0) {
            complete()
        }
    }
}

これでadd、remove、complete、全て揃いました。

この様に、TDDは、設計と実装と動作確認が同時にできます。
しかも、ビルド時間も普通にアプリ起動して所定の操作をするより何十倍も早いはず。
TDDによって工数が増えるかと思いきや、逆に工数が減ります。

いかがでしたでしょうか

今回はシンプルな例を選びました。
実際は、マルチスレッドだったり非同期コールバックだったりと、様々なモデルがあると思います。
弊社において、その際はテストにcoroutineを用いて待ち合わせを行ない、モデル側もcoroutineに書き換える様にしています。
機会があれば、その辺りもご紹介できればと思います。

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?