NHN Cloud NHN Cloud Meetup!

[Kotlin]テストコード作成のための効率的なアプローチとは

多くの方が「テストコード」の作成が難しいと感じ、それが本当に「必要なのか」と悩むことがあるでしょう。
テストコードの作成が果たして「必要なのか」に対する答えは、おそらく存在しません。実行しているドメインロジックにテストコードカバレッジがどの程度必要なのかは、実際にはあまり重要ではないからです。
本当に重要なことは、「コード」が正常に動作していることを「確信」できること、メンバーの大半が共感できることにあると思います。今回は、どのようにすればテストコードを簡単に、効率的に作成できるかについて考えてみます。

Androidのコードベースで作成しました。)

はじめに

次のような簡単なアプリがあると仮定して、開発/テストをしてみましょう。

要件

  1. アンドロイドアプリが実行されると、1つのButtonと2つのTextViewがある画面が表示される。
  2. Buttonをクリックすると、1つのTextViewに1から順番に1ずつ数字が増える。
  3. TextViewに3/6/9の数字が入ると、TextViewに「ペア」という文字が表示され、数字が入らない場合は、「空の文字列」が表示される。

第1プログラム

Source

簡単に理解するため、すべてのViewに該当する部分は、xmlではなくコードで表します。(原則として、AndroidでView関連のコードは、xmlで表現します。)
次のような非常に簡単なコードがあります。

class MainActivity : AppCompatActivity() {
    companion object {
        private const val PLUS_BUTTON_TEXT = "+"
        private const val CLAP_TEXT = "ペア"
    }

    private lateinit var numberTextView: TextView // 数字を表示するView
    private lateinit var clapTextView: TextView // 3/6/9の数字が入ると、「ペア」を表示するView
    private lateinit var button: Button // 数字を増加させるView

    private var number = 0 // 数字の値をカウントする変数

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // レアアウトビュー生成(全体Viewを含めるコンテナ)
        val linear = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }

        numberTextView = TextView(this.applicationContext)
        clapTextView = TextView(this.applicationContext)
        button = Button(this.applicationContext).apply {
            text = PLUS_BUTTON_TEXT

            // ボタンクリック時、数字の増加や3/6/9があるかをチェック後、TextViewに表示
            setOnClickListener {
                number++
                numberTextView.text = "$number"

                // 3/6/9 があるかをチェックするロジック
                var isContained = false
                run loop@{
                    listOf("3", "6", "9").forEach {
                        val numberString = number.toString()
                        isContained = numberString.contains(it)
                        if (isContained) {
                            return@loop
                        }
                    }
                }

                if (isContained) {
                    clapTextView.text = CLAP_TEXT
                } else {
                    clapTextView.text = ""
                }
            }
        }

        // ビュー追加
        linear.run{
            addView(numberTextView)
            addView(clapTextView)
            addView(button)
        }

        setContentView(linear)
    }
}

Test

上記のプログラムは、onCreateというコールバック上にすべてが定義されています。下位ビューの作成、ボタンをクリックするデータ値の変更、計算、ビューの変更など、すべてのロジックが1つに含まれているので、次のようにテストコードを書くことができますね。

class MainActivityTest {

    @Test
    fun onCreate() {
        val mainActivity = MainActivity()
        mainActivity.onCreate(null, null)
    }
}

単に外部からMainActivityのonCreate()メソッドを呼び出すテストコードです。

実行結果は次のとおりです。

しかし、実行テストが成功していません!

理由は、単体テストを実行するとき、Android SDKのパスがandroid.jarではなく、android-stubs-src.jarを使用するためで、当該ファイルのクラスやメソッドは、実際には実装がない空のシェルであるため、JUnit上で正常な動作ができないからです。

Next

では、JUnitテストはどうすればよいでしょうか?
答えは簡単です。AndroidコードAndroid非依存コードを分離します。

第2プログラム

いくつか方法がありますが、次のように分離してみましょう。

Source

3/6/9が含まれているか(1〜9の値のうち、3の倍数であること)をチェックするロジックを、contains369()メソッドで作ったことに注目しましょう。

また、contains369()メソッドでメンバ変数であるnumberを参照するのではなく、別途パラメータとして受け取るように設計したことも、注目する必要がありますね。このように強い結合を切断し、外部から値を受け取るように設定すると、当該部分はテストが可能なロジックになります。

また、contains369()メソッドは、常に同じ入力に同じ結果を付与する純粋な関数として、MainActivityのいずれの状態も変更しないため、外部に公開しても「安全なメソッド」と言えます。(そのため、publicにアクセサを指定しました。==外部呼び出し可能==テスト可能)

class MainActivity : AppCompatActivity() {
    companion object {
        private const val PLUS_BUTTON_TEXT = "+"
        private const val CLAP_TEXT = "ペア"
    }

    private lateinit var numberTextView: TextView // 数字を表示するView
    private lateinit var clapTextView: TextView // 3/6/9の数字が入ると、「ペア」を表示するView
    private lateinit var button: Button // 数字を増加させるView

    private var number = 0 // 数字の値をカウントする変数

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(makeView())
    }

    private fun makeView(): View {
        val linear = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }

        numberTextView = TextView(this.applicationContext)
        clapTextView = TextView(this.applicationContext)
        button = Button(this.applicationContext).apply {
            text = PLUS_BUTTON_TEXT

            // ボタンクリック時、数字の増加や3/6/9があるかをチェック後、TextViewに表示
            setOnClickListener {
                number++
                numberTextView.text = "$number"

                // 3/6/9 があるかをチェックするロジック
                if (contains369(number)) {
                    clapTextView.text = CLAP_TEXT
                } else {
                    clapTextView.text = ""
                }
            }
        }

        linear.run{
            addView(numberTextView)
            addView(clapTextView)
            addView(button)
        }

        return linear
    }

    fun contains369(num: Int): Boolean {
        listOf("3", "6", "9").forEach {
            val numberString = num.toString()
            if (numberString.contains(it)) {
                return true
            }
        }

        return false
    }
}

Test

MainActivityのインスタンスを生成して、テストを行っていることが分かります。Activityを直接生成してはいますが、Androidのフレームワークコードを追う部分が全くなく、単体テストが正常に動作していることが分かります。

    @Test
    fun `3/6/9が入っているかどうかを確認するテスト`(){
        val mainActivity = MainActivity()

        // 0から100までは、3/6/9
        val testSet = hashMapOf(
            Pair(true, listOf(3,6,9,13,16,203,216,999,1033)),
            Pair(false, listOf(0,1,2,4,5,10,17,28,40,41,47,71,507,788))
        )

        testSet[true]?.forEach {
            assertEquals(true, mainActivity.contains369(it))
        }

        testSet[false]?.forEach {
            assertEquals(false, mainActivity.contains369(it))
        }
    }
}

Next

もう少し素敵なテストコードを作成するにはどうすればよいでしょうか?
テストコードの作成が困難な理由は、「拡張」について開いており、「修正」については閉じられている開放閉鎖原則が守られていないことと、様々な機能が一ケ所に集中して、「責任分離の原則」が守られていないことです。

現在のコードは、Activity(厳密に言えばView単位)ですべて行われているので、コアビジネスロジックのcontains369()ロジックが変更されると、ViewロジックがあるActivityクラスを変更しなければならないリスクがあります。また、様々な変化の可能性については、閉じていることが分かります。

MainActivityTestクラスでビジネスロジックを検査すること自体がおかしい状況なので、最後のステップでビジネスロジックを分離してみましょう。

第3プログラム

Viewを作成するコードはすべてMainActivityに置いて、残りは移動させましょう。

主要なビジネスロジックを担当するGameControllerです。当該クラスにcontains369をもう少し一般化して、contains()メソッドを使用します。さらに、369を含むかどうかに加えて、含まれている個数までカウントしてくれるメソッドも作成できます。(countOf369()、countOf()メソッド)

// GameController.kt
class GameController {

    fun contains369(num: Int): Boolean = contains(listOf("3", "6", "9"), num)

    private fun contains(list: List<String>, num: Int): Boolean {
        list.forEach {
            val numberString = num.toString()
            if (numberString.contains(it)) {
                return true
            }
        }

        return false
    }

    fun countOf369(num: Int): Int = countOf(listOf("3", "6", "9"), num)

    private fun countOf(list: List<String>, num: Int): Int {
        var count = 0
        list.forEach {
            val numberString = num.toString()
            if (numberString.contains(it)) {
                count++
            }
        }

        return count
    }
}

Android AACで提供されるViewModelを利用して、次のようなViewModelクラスを構成しました。Activityは単にViewウィジェットと、Viewウィジェットのアクション(クリック等)を宣言するのに対し、GameModelではビジネスロジックとViewをつなぎあわせて、実際のアプリのデータを管理していることが分かります。

// GameModel.kt
class GameViewModel: ViewModel() {
    private val gameController = GameController()

    val number = MutableLiveData<Int>()
    val contains = MutableLiveData<Boolean>()

    init {
        number.value = 0
        contains.value = false
    }

    fun increaseNumber(diff: Int = 1) {
        number.value = number.value?.plus(diff)

        contains.value = gameController.contains369(number.value!!)
    }
}

最後に、Viewだけを担当するActivityです。はじめと比べてViewに関連するコードだけを確認することができます。Viewを定義し、各Viewのイベント(クリックなど)を定義し、データが変更されたとき(observe)、Viewに反映するロジックのみが含まれていることが分かります。

// MainActivity.kt
class MainActivity : AppCompatActivity() {
    companion object {
        private const val PLUS_BUTTON_TEXT = "+"
        private const val CLAP_TEXT = "ペア"
    }

    private lateinit var numberTextView: TextView // 数字を表すView
    private lateinit var clapTextView: TextView // 3/6/9の数字が入ると、「ペア」を表すView
    private lateinit var button: Button // 数字を増加させるView
    private lateinit var viewModel: GameViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        viewModel = ViewModelProviders.of(this).get(GameViewModel::class.java)
        viewModel.number.observe(this, object: Observer<Int> {
            override fun onChanged(number: Int?) {
                numberTextView.text = "$number"
            }
        })

        viewModel.contains.observe(this, object: Observer<Boolean> {
            override fun onChanged(contains: Boolean?)
                    = if (contains!!) {
                        clapTextView.text = MainActivity.CLAP_TEXT
                    } else {
                        clapTextView.text = ""
                    }
        })

        setContentView(makeView())
    }

    private fun makeView(): View {
        val linear = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }

        numberTextView = TextView(this.applicationContext)
        clapTextView = TextView(this.applicationContext)
        button = Button(this.applicationContext).apply {
            text = PLUS_BUTTON_TEXT
            setOnClickListener {
                viewModel.increaseNumber()
            }
        }

        linear.run{
            addView(numberTextView)
            addView(clapTextView)
            addView(button)
        }

        return linear
    }
}

まとめ

開発には王道はありませんが、「定石」に近づく道があり、より効率的な道があると思います。コードの凝集度高め、結合度を弱めることが、最終的な「テストコード」の組みやすさに通じます。

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop