スクラムチームで始めたAndroidアプリのテスト駆動開発

こんにちは!kintoneチーム所属のAndroidエンジニア、トニオ(@tonionagauzzi)です。
サイボウズでは、スクラムマスター(SM)の仕事をより多くの方々に知ってもらう啓蒙活動の一環として、リレーブログ企画を開催しています。

blog.cybozu.io

  • 対象者: スクラムマスターが近くにいるけどありがたみを感じたことがない人
  • 学習成果: 記事の内容に関連する悩みについて、近くのスクラムマスターに相談できるようになる

この記事では、スクラムの開発チーム目線で、私が所属するAndroidチームが実践しているテスト駆動開発TDD)という手法を紹介します。
この記事を通じて、社外のみなさまに私たちの開発のやりかたや雰囲気を知っていただき、サイボウズに応募するきっかけとなったり、私たちと話す際のネタになれば嬉しいです。

記事のテーマ

スクラムチームで始めたAndroidアプリのテスト駆動開発

テスト駆動開発(TDD)とは

TDDは、テストを足がかりにしてシステムの振る舞いを変えるためのプログラミングワークフローです。
TDDの定義を詳しく知りたい方には、以下の書籍をオススメします。 www.google.co.jp

TDD導入の背景

kintone Androidは、リリースから何年も経過しているアプリで、一部のコードがすでに古くなっています。
もし、最近のデファクトスタンダードに沿った実装になれば、

  1. テストを書く手段が充実しており、人力での品質保証コストを減らせる
  2. 読みやすく保守性の高いコードを書けるようになり、開発コストも減らせる
  3. 業界標準の技術を使っていることで、採用のハードルが下がる

などのメリットがあります。

しかし、テストコードがない実装、いわゆるレガシーコードを一気に書き換えるのは、以下の観点で難しいです。

レガシーコードの書き換えは歓迎されにくい

すでにリリース済みのコードを、新機能開発よりも優先して動作を変えないように修正することは、開発チーム以外からは通常は望まれないことです。
PM、PO、ステークホルダーからすれば、テストよりも次の機能やユーザー価値に繋がる改善をしてほしいはずなので、コードを書き直すには、彼らを説得する明確な理由が必要です。

安心してリリースしにくい

開発者が時間を捻出してコードを書き直したとしても、開発に直接関わらない人がリリースに同意しないことはよくあります。
開発者が「この変更はバグがないし自信があるし大丈夫!」と言い張っていても、そうでない人が安心材料を見つけるのは困難だからです。
せっかくのリファクタリングが受け入れられなければ、開発者がやる気を失ってしまうこともあります。

人力のテストを続ける場合、小さい変更に留めようとしてしまう

実装を大きく変えると、そのぶんだけ影響範囲が広がります。
広い範囲を人力でテストする場合、同じテストを繰り返し実行するのが難しく、機械よりも結果が出るのが遅い傾向があります。
そのため、バグを恐れてより慎重に実装を変更しようとします。大きな変更を恐れ、小さな変更しかしない状態に陥ってしまいます。

人力のテストはスケールしにくい

結果を早く得るために複数人で並列にテストしようとしたり、テストの馬力を上げようとすると、テスターの育成や学習にかかるコストが都度発生します。
複数人のテスターが常に準備完了していることはほぼありません。

一度行った人力のテストを自動テストに変えにくい

人力コストを抑えるために自動テストを書くのはよくある話です。
ですが、検証方法を変えると、検証方法の変更を慎重にレビューする必要があります。
以前と同じチェックができていないとバグの見落としが起きうるからです。
そのため、以前の検証方法をできるだけ変えたくないという心理が働きます。
開発エンジニアとQAエンジニアの視点や技術の違い、お互いの作業を知らないことで不安が生じることもあります。

以上の事情で、新機能を実装するコストが年々上がっており、レガシーコード脱却の機運が高まっている状況でした。
そんな中で、私が認定スクラムデベロッパー研修に参加し、TDDという手法を学び、チームに持ち帰って実践することができました。

TDD導入の目的

この段階でTDDを導入することは、

  1. 今後の新機能開発でテストを書く習慣が身に付く
  2. 新機能のテストが増えるに従い、既存機能のテストも増える
  3. 既存機能のテストが増えると、安全なリファクタリングが可能になる
  4. ボーイスカウトルールで少しずつレガシーコードが減る!

などの成果が挙がるのではないかと考えました。

実践しないことには何もわからないので、数スプリントの間、TDDを実践してみて検証することにしました。

TDDの流れ

私たちのTDDは、以下のサイクルで行うことにしました。

TDDのサイクル。チェックリストを作り、テスト、開発、リファクタを繰り返してチェックをつけていく

  1. 実装プランニングでチェックリストを作成
  2. テスト、実装、リファクタリングを繰り返す
  3. すべてチェックしたら、レビューしてマージする

以下のプロダクトバックログアイテム(PBI)を例に説明します。

# やりたいこと
ログイン画面にヘルプリンクを追加したい

# ユーザーストーリー
ログイン操作を行うユーザーとして、
ログイン操作につまずいたとき、その画面上から直接関連するヘルプページを開きたい。
なぜなら、疑問の答えを一から探す手間をかけずに関連するヘルプページを参照し、ログインを完了させ、本来やりたい業務を進められるからだ。

# 受け入れ条件
- ログイン画面からヘルプページを開けること
- 端末の言語設定に応じてヘルプの言語を変えること
- 接続先ごとに提供サービスが異なるため、接続先に応じてヘルプの内容を変えること

1. 実装プランニングでチェックリストを作成

スプリントの最初に、今回着手するPBIの実装プランニングを行います。
実装プランニングは、What:何を作りたいかを、How:どう作るかに落とし込むイベントです。

何を作りたいかはPBIの受け入れ条件に書かれているので、受け入れ条件をもとに、開発チームとして達成したいことを

  • 必須事項
  • 任意事項

に分解します。

# 必須事項
- 端末の言語設定と接続先情報に応じたヘルプページのURLを生成する
- ログイン画面でヘルプリンクを押すと、生成したURLをChromeに渡し、ヘルプページを開く

# 任意事項
- Jetpack Composeで実装する

任意事項は重要ではないので、今回は触れません。

次に、必須事項を満たす上で保証したい品質の観点を、テスト観点のチェックリストにします。

# テスト観点のチェックリスト
☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ☑️ 日本語 / 日本サーバー の組合せ
  ☑️ 英語 / 日本サーバー の組合せ
  ☑️ 簡体字 / 日本サーバー の組合せ
  ☑️ 繁体字 / 日本サーバー の組合せ
  ☑️ その他 / 日本サーバー の組合せ
  ☑️ 日本語 / 米国サーバー の組合せ
  ☑️ 英語 / 米国サーバー の組合せ
  ☑️ 簡体字 / 米国サーバー の組合せ
  ☑️ 繁体字 / 米国サーバー の組合せ
  ☑️ その他 / 米国サーバー の組合せ
☑️ 接続先を判定できなかった場合、日本語 / 日本サーバー の組合せとなる

☑️ (2) ログイン画面でヘルプリンクが押されると、1.で生成したURLをChromeに渡し、ヘルプページを開く

チェックリストは、必須事項の文章をそのまま引用することもあれば、もっと細分化することもあります。
今回の例では細分化しています。

2. テスト、実装、リファクタリングを繰り返す

1つ目のテスト

チェックリストの最初のチェックを、いきなりテストコードで書きます。

☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ✏︎ 日本語 / 日本サーバー の組合せ

上記の部分です。
Androidのユニットテストで書くと、以下のようになります。

@Test
fun `日本語 / 日本サーバー の組合せで、ヘルプページのURLを生成する` {
    // Given(前提条件)
    val language = "ja"
    val country = "JP"

    // When(操作)
    val url = get(language = language, country = country)

    // Then(結果)
    Truth.assertThat(url).isEqualTo("https://localhost/help/ja/JP/")
}

実装側は、以下のように未実装にしておきます。

// 実装
fun get(language: String, country: String): String {
    return ""
}

ここでテストを実行すると、当然テストは失敗に終わります。ここでは便宜上、テストが失敗したことをチェックリストに記録します。

☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ❌ 日本語 / 日本サーバー の組合せ

1つ目の実装

テストが成功するように最小限の実装を書きます。

// 実装
fun get(language: String, country: String): String {
    return "https://localhost/help/ja/JP/"
}

テストが成功する文字列をそのまんま返すだけです。1回目の実装では、先のことを考えて条件文を書く必要はありません。何もかも後で考えましょう!

再度テストを実行すると、テストは成功します。テストが成功したことをチェックリストに記録します。

☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ✅ 日本語 / 日本サーバー の組合せ

チェックが入ると気分がいいですね!

2つ目のテスト

次のテストケースを追加します。

☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ✅ 日本語 / 日本サーバー の組合せ
  ✏︎ 英語 / 日本サーバー の組合せ
@Test
fun `日本語 / 日本サーバー の組合せで、ヘルプページのURLを生成する` {
    // Given
    val language = "ja"
    val country = "JP"

    // When
    val url = get(language = language, country = country)

    // Then
    Truth.assertThat(url).isEqualTo("https://localhost/help/ja/JP/")
}

@Test
fun `英語 / 日本サーバー の組合せで、ヘルプページのURLを生成する` {
    // Given
    val language = "en"
    val country = "JP"

    // When
    val url = get(language = language, country = country)

    // Then
    Truth.assertThat(url).isEqualTo("https://localhost/help/en/JP/")
}

そのままテストを実行すると、1つ目のテストは成功しますが、2つ目のテストは失敗します。

☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ✅ 日本語 / 日本サーバー の組合せ
  ❌ 英語 / 日本サーバー の組合せ

2つ目の実装

2つのテストが成功する実装に変えます。ここで条件分岐が登場しますが、3つ目以降のテストのことはまだ考えません。

// 実装
fun get(language: String, country: String): String {
    if (language == "en") {
        return "https://localhost/help/en/JP/"
    } else {
        return "https://localhost/help/ja/JP/"
    }
}

これでテストが2つ成功しました。

☑️ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ✅ 日本語 / 日本サーバー の組合せ
  ✅ 英語 / 日本サーバー の組合せ

リファクタリング

次のテストケースを追加する前に、メソッド名や書き方などを改善しておきます。2つ目のテストケース以降では、このリファクタリング作業を行います。

// 実装
fun generateHelpLink(language: String, country: String): String {
    val helpPath = when (language) {
        "en" -> "en/JP"
        else -> "ja/JP"
    }
    return "https://localhost/help/$helpPath/"
}

変更を加えても、これまでに書いたテストは引き続き通るので、動作が保証されるため安心してリファクタリングできます。

残りのテスト、実装、リファクタリング

1つずつ順にテスト、実装、リファクタリングをしていくと、(1)のチェックリストがすべてチェック済みになります。

✅ (1) 接続先情報と端末の言語設定に応じたヘルプページのURLを生成する
  ✅ 日本語 / 日本サーバー の組合せ
  ✅ 英語 / 日本サーバー の組合せ
  ✅ 簡体字 / 日本サーバー の組合せ
  ✅ 繁体字 / 日本サーバー の組合せ
  ✅ その他 / 日本サーバー の組合せ
  ✅ 日本語 / 米国サーバー の組合せ
  ✅ 英語 / 米国サーバー の組合せ
  ✅ 簡体字 / 米国サーバー の組合せ
  ✅ 繁体字 / 米国サーバー の組合せ
  ✅ その他 / 米国サーバー の組合せ
✅ 接続先を判定できなかった場合、日本語 / 日本サーバー の組合せとなる

簡体字と繁体字の判定が複雑なので実装は省略しますが、綺麗にリファクタリングが行われた(1)の完成形コードがこの時点で手元にあるはずです。

Androidアプリ特有のテスト

次は(2)のテストです。

☑️ 2. ログイン画面でヘルプリンクが押されると、1.で生成したURLをChromeに渡し、ヘルプページを開く

このテストはユーザー操作が入るので一見難しいように思えますが、実はユニットテストで簡単に書けます

private val activity = Robolectric.buildActivity(FragmentActivity::class.java)
    .create()
    .start()
    .resume()
    .get()

@Test
fun `ログイン画面でヘルプリンクが押されると、生成したURLをChromeに渡し、ヘルプページを開く`() {
    // Given (前提条件: ログイン画面を開いている)
    val fragment = Fragment()
    activity.add(fragment)
    val helpLinkUrl = generateHelpLink(language = "ja", country = "JP")

    // When (操作:ヘルプリンクを押す)
    fragment.openBrowser(
        url = helpLinkUrl,
        errorCallback = { e -> fail(e) }
    )

    // Then (結果:ChromeがURLを開く)
    val openedActivity = Shadows.shadowOf(activity).nextStartedActivity
    val openedPackage = openedActivity.getPackage()
    Truth.assertThat(openedPackage).isEqualTo("com.android.chrome")
    val openedUrlString = openedActivity.data.toString()
    Truth.assertThat(openedUrlString).isEqualTo(helpLinkUrl)
}
// 実装
fun Fragment.openBrowser(
    url: String,
    errorCallback: (e: Exception) -> Unit = { e ->
        // エラー処理
    },
) {
    try {
        val chromeIntent = Intent(Intent.ACTION_VIEW, url.toUri())
        chromeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
        chromeIntent.setPackage("com.android.chrome")
        startActivity(chromeIntent)
    } catch (e: Exception) {
        errorCallback(e)
    }
}

Chromeが立ち上がって指定したURLが開くという挙動を、ユニットテスト用のRobolectricを使ってテストできました。

✅ 2. ログイン画面でヘルプリンクが押されると、1.で生成したURLをChromeに渡し、ヘルプページを開く

Androidアプリ特有の事情として、RobolectricやandroidTest用のEspresso、UI Automatorなどの支援ツールが充実しています。
私たちはテストピラミッドの考え方に従い、機能単体の試験は可能な限りユニットテストで書くようにしています。

たまに手動での追加機能試験が必要なこともありますが、TDDを始めてから、多くの機能試験はユニットテストやandroidTestで事足りるようになりました。

これで、チェックリストを満たすすべてのテストと実装が揃いました。

3. すべてチェックしたら、レビューしてマージする

チェックリストをすべてチェックしたら、コードをレビューします。実装に深く関わっていなかった人の意見や、実装に関わった人があらためて気づいたことが出てきたらコードを修正します。
修正したら、今までに書いたテストがすべて通る状態でコミットします。修正箇所がなくなり、すべてのテストが成功していればコードベースにマージし、PBIを完了します。
レビューの修正も、テストが充実していることでやりやすくなりました。

TDDを導入して感じたメリット

TDDを導入してから1か月後にふりかえりを行ったところ、以下のメリットが挙がりました。

実装に取り掛かりやすい

大きな機能をまとめて実装するのは大変でしたが、チェックリストに沿って小さく実装を進めていくことで、実装に取り掛かる心理的負荷が減りました。

また、受け入れ条件に対して間違った解釈をしたまま作り込んでしまったり、レビュー段階で手戻りを起こしたり、いま必要のない機能まで作ってしまうこともほぼなくなりました。

実装に自信を持ちやすい

TDDでない実装の場合、実装の終盤に「本当にこれで合ってるのかな?」「レビューを依頼したら作り直すように言われないかな?」と思うこともありますが、TDDであればチェックリストに沿ったテストがすべて通過していることが正しさを証明してくれるので、自信を持てるようになりました。

壊れにくい

TDDベースの実装を書き換える際、以前作った自動テストが揃っていることで、自信を持って大胆に書き換えられることを実感しました。
また、レガシーコードに対して局所的にでもTDDをすると、それ以外の部分にも少しテストが広がって、壊れにくく安心して変更を加えられるプロダクトに近づいているような気がしました。

テストの整備が進む

テストを増やすうちに「これパラメタライズドテストで1つのテストケースにまとめられるんじゃない?」「リリース前の手動回帰試験も一部省略できるんじゃない?」などの話し合いが起き、どんどんテストと開発プロセスが改良され、メンバー間のコミュニケーションも活発になりました。

TDDが難しい場合

一方で、TDDが難しいと感じた場面もいくつかありました。
以下の場合はTDDを行わず、テストを省略することも検討しました。

自信がある

ログを1つだけ追加するなど、これはまちがいなく期待通りに動く!と自信を持てる場合もあります。そういうときは、ルールに沿ってTDDをしようとする気持ちがむしろ足枷になることがあります。余剰なテストを作り込んでしまったり、他のことに使いたかった時間を失うこともあるからです。

画面の見た目が変わるような実装

画面レイアウトが変わったことを確かめたかったり、反対に何かのアップデートで画面レイアウトが意図せず変わっていないことを確かめたい場合、TDDは必ずしもマッチしません。画面系のユニットテストは難易度やコストが高い場合が多いので、画面回帰テスト(VRT)などが向いています。

テストコードだけではカバーしきれない

テストコードの不足や誤りに気づかないまま、テストのグリーン表示✅に安心してしまい、バグを見逃してしまうこともあります。テストの不足や誤りがないかは常に疑うようにしたいです。

認識が揃っていない

TDDやテストに対するメンバーの知識や賛成/反対の意見などが揃っていない場合、議論が長引いてTDDのスピードが上がらないことがあります。テスト、実装、リファクタリングの1サイクルは数分ほどで行われることも多いので、1サイクルに何十分もかけているようでは、TDDがうまくいっていません。
その場合、TDDをゴリ押しするよりも、メンバーの知識や方向性を揃えるほうが大事です。そのためには読書会を開催したり、特定の1タスクのみTDDを導入してみるなど、小さく実験してみることが有効です。

自動テストか手動テストか

ここまで、自動テストを書く前提でTDDを述べましたが、自動テストが必須というわけではありません。
自動テストは多ければ多いほど良いというものではなく、むやみに増やすと実行時間やCI/CDへの課金額などが上がってしまうので、自動化するテストには優先順位付けが必要です。

私たちは、顧客にとって価値が高く、手動で試験するとコストが高いテストを優先して自動化したいと考えています。

自動化するテストは、顧客にとって価値が高く、手動で試験するものが優先

顧客にとって価値が低い機能であれば、テストを書くかどうか以前に実装されないことが多いです。
一方、顧客にとって価値が高いけれど手動でも試験しやすい機能であれば、手動で機能を触りながらチェックリストにチェックを入れていくという開発手法もTDDの戦略として有効です。

自動テストを書くことはあくまで方法の1つであり、固執したり守るべきルールではないと考えています。

まとめ

TDDでは、実装プランニングでチェックリストを作り、そのリストにしたがってテストと実装、リファクタリングを繰り返します。
テストから書くルール付けやテストが増えることは副次的効果に過ぎず、もっとも大事なことはTDDによって実装に自信を持ちやすいことだと思います。

私たちのTDDはこれで完成形ではないので、今後も改善を重ねつつ、ユーザー価値の高い機能を開発します。

スクラムマスターって何するの?に対する私なりの答え

ここまでは、私たちが行っているTDDについて説明しました。ここで冒頭に立ち返ると、私たちの仕事をスクラムマスターの仕事の一環として知っていただくために、リレーブログ企画として本記事を執筆しました。

私が考えているスクラムマスターの仕事は、スクラムイベントが機能していることに責任を持ち、機能していなければ何でもすることだと思っています。

たとえば、

  • チームの中に対して、
    • スクラムの理解が不足していたら、チームに対して理解を促進する
    • コードの問題があれば、チームに対して直すように働きかけたり、自ら直すこともする
    • プロダクトオーナーの仕事が多ければ、プロダクトオーナーを手伝う
  • チームの外に対して、
    • プロダクトがより市場価値を高められるように、組織の構造や開発体制、ルールなどを変えるよう広範囲の人に働きかける
    • チームがチーム外の人と対話しやすい状況を作る

ただし、これらはスクラムマスターに任せきりにするのではなく、逆にスクラムマスターがいなくてもチームが円滑に回ることを目標とします。
そのためには、スクラムマスターはいろんなことをやりつつ、チームメンバーの意欲を引き出し、後押しすることも大事です。
スクラムマスターではない人も、スクラムマスターの役割を果たさないということはありません。

今回のような技術的なプラクティスの支援も、スクラムマスターの活動のひとつです。TDDという言葉を聞いたことはあるけれど実践したことはない方は、よければ身近なスクラムマスターに相談してみてください。きっと親身に相談に乗ってくれるはずです。
私自身(@tonionagauzzi)もスクラムマスターのカンファレンスに参加したり発信をしたりする中で皆様と触れ合うことがあると思いますし、DMでもお話を聞きますので、ぜひ気軽にお声かけください。

さいごに

サイボウズでは、Androidエンジニアを募集中です。私たちとともにチームワークあふれる社会創りに挑戦してくださる方の募集お待ちしております!

cybozu.co.jp