 すだ
すだみなさまこんにちは〜!
メモリアインクのすだです。
今回は、Androidアプリ開発におけるJUnit5を使ったテストの方法についてわかりやすく解説していきます。
この記事を読んでわかること…
・JUnitとは?
・JUnit5の導入方法
・JUnitを使ったテストの基本の書き方
・JUnitを使った実践的な処理のテストの書き方
環境
- Kotlin (ver 1.9.0)
- Android Studio (Giraffe | 2022.3.1 Patch 3)
JUnit5とは?
JUnitとは Java/Kotlinで書かれたプログラムに対して、動作を自動で検証するためのテストフレームワークです(Unit Test Framework)。
JUnit5(Jupiter)は JUnit4の後継バージョンで、 
「テストをもっとモジューラー(分割性)に、軟軒に」するために生まれました。
Androidアプリでも、ViewModel(画面のロジックを担当するクラス)やUseCase(ビジネスロジックを担当するクラス)などに対して必要不可欠な技術です。
Androidでは、JUnitを使って「ロジックの正しさ」を確認するテストを書くのが一般的です。
基本はコードを実行し、期待される結果を検証するという形です。
たとえば、2 + 3を渡して5が返ってきたら「正しく動いている」という結果 になるということです。
また、Androidアプリでは、本番のアプリコード(Activity、Fragmentなど)とテストコード(JUnitを使ったテスト) この2つをちゃんと「別の場所に分けて」書くのが基本です。
具体的にどういう構成かというと、
app/
├── src/
│   ├── main/         ← 本番コード(アプリ本体)
│   │   └── java/
│   │       └── com.example.app/
│   │           └── MainViewModel.kt  ← 本番用クラス
│   │
│   ├── test/         ← 単体テスト(JUnitモジュール)
│   │   └── java/
│   │       └── com.example.app/
│   │           └── MainViewModelTest.kt  ← テストコード
│   │
│   └── androidTest/  ← UIテスト(Jetpack Compose Testingなど)- main/はアプリ本番のコード
- test/はJUnitだけのテスト用コード
- androidTest/は画面操作(UI)をテストするためのコード
つまり、本番アプリと、テストのコードは物理的に別のフォルダに分けて書くということになります。
AndroidのJUnit5環境を構築する
JUnitでテストを行うにあたって、以下をアプリレベルのbuilg.gradleに追加してください。
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
tasks.withType<Test> {
    useJUnitPlatform()
}(useJUnitPlatform() を追加しないと、JUnit5テストが起動しません)
テストのために使うライブラリは、implementation ではなく testImplementation を使って指定します。
これによって、アプリ自体には影響を与えず、テストコードだけに必要なライブラリだけを取り込むことができます。
基本的なJUnit5テストコード
それでは、
- 2つの数字を足し算するだけのクラス
- そのクラスの処理をテストするためのクラス(JUnit)
という基本的なコードを書いていきます。
ファイルは、分けて以下のような構成で作成します。
app/
├── src/
│   ├── main/
│   │   └── java/
│   │       └── com.example.app/
│   │           └── Calculator.kt  ← 本番コード
│   ├── test/
│   │   └── java/
│   │       └── com.example.app/
│   │           └── CalculatorTest.kt  ← テストコードCalculator.kt (本番コード)▼
class Calculator {
    fun add(a: Int, b: Int): Int = a + b
}CalculatorTest.kt(テストコード)▼
class CalculatorTest {
    private val calculator = Calculator()
    @Test
    fun `2+3は5になる`() {
        val result = calculator.add(2, 3)
        assertEquals(5, result)
    }
}@Testというアノテーションをつけることによって、それがテストコードになります。
これがついていないと、JUnitはこの関数をテスト対象として認識しません。
逆に@Testがついている関数は、「ビルドして自走で走るテスト対象」として動きます。
名前に「2+3は5になる」と自然言語のように書いてるのは、
「何をテストしてるのか明確にする」ためにこのようにしたりします。
そして、assertEquals()はJUnitテストの中で「結果が正しいかどうか」をチェックするための関数(アサーション)です。「期待してる値」と「実際に出た値」が同じかを自動で比較してくれる関数です。
assertEquals(期待する値, 実際の値)のように書いていきます。
他にも、
- assertTrue(条件がtrueか をチェック)
- assertFalse(条件がfalseか をチェック)
- assertNotNull(条件がnullじゃないか をチェック)
のように様々なassert関数が存在します。
テストの実行方法
基本のテストコードが書けたら、テストを実行していきます。
クラス単位でテストを実行する


クラス名横の緑色三角ボタンを押下して、


Run 'CalculatorTest' を押下すると、テストが走ります。
テストが走るとログが出力され、
最後にBUILD SUCCESSFUL in ~ と表示されると ビルド&テストが成功して全部通ったということになります。
メソッド単位でテストを実行する


メソッド単位のテストも同様に、メソッド名横の緑色三角ボタンを押下して Run 'CalculatorTest' を押下すると、テストが走ります。
実践:Kotlinクラスをテストする
基本の構文がわかったところで、
次はより実践的なテストコードを書いていきます。
ビジネスロジックを担当するUseCaseのテスト
本番コード▼
class LoginUseCase {
    fun login(email: String, password: String): Boolean {
        return email.isNotBlank() && password.length >= 8
    }
}上記は、条件が二つあります。
- メールアドレスが空ではないこと
- パスワードが8文字以上であること
この2つが両方満たされて初めて true を返す、どちらか一方でもダメなら false を返す
ということになります。
これを網羅的にテストするには、最低限以下パターンをカバーしたいです。
| テストケース | password | 期待される結果 | |
|---|---|---|---|
| 正常系(両方OK) | 空じゃない | 8文字以上 | true | 
| 異常系1(emailが空) | 空 | 8文字以上 | false | 
| 異常系2(passwordが短い) | 空じゃない | 7文字以下 | false | 
| 異常系3(両方ダメ) | 空 | 7文字以下 | false | 
この4パターンをテストコードへ落としていきます。
テストコード▼
class LoginUseCaseTest {
    private val useCase = LoginUseCase()
    @Test
    fun `正常系 - メールあり、パスワード8文字以上なら成功`() {
        val result = useCase.login("test@example.com", "password123")
        assertEquals(true, result)
    }
    @Test
    fun `異常系 - メールが空なら失敗`() {
        val result = useCase.login("", "password123")
        assertEquals(false, result)
    }
    @Test
    fun `異常系 - パスワードが8文字未満なら失敗`() {
        val result = useCase.login("test@example.com", "pass")
        assertEquals(false, result)
    }
    @Test
    fun `異常系 - メールが空かつパスワードが短いなら失敗`() {
        val result = useCase.login("", "pass")
        assertEquals(false, result)
    }
}また、上記のように「違う入力値ごとに毎回別々のテスト関数」を書く必要がある場合は、
パラメータ化テスト(@ParameterizedTest) としてまとめて書くことができます。
例えば、上記のLoginUseCaseに対して、以下のように書くことができます。
class LoginUseCaseTest {
    private val useCase = LoginUseCase()
    @ParameterizedTest
    @CsvSource(
        "'test@example.com', 'password123', true",
        "'', 'password123', false",
        "'test@example.com', 'short', false",
        "'', 'short', false"
    )
    fun `login test`(email: String, password: String, expected: Boolean) {
        val result = useCase.login(email, password)
        assertEquals(expected, result)
    }
}@ParameterizedTestというアノテーションをつけて「この関数はパラメータを変えて何度も実行する」という宣言をして、@CsvSourceというアノテーションでテストしたいデータをリスト形式で渡します。
このように書くことで、email, password, expected が、毎回自動で違う値を受け取ってテストされるようになります。
画面ロジックを管理するViewModelのテスト
下記は、入力フォームの値を管理するViewModelの処理です。
name が入力されたら値が更新され、「保存完了メッセージ」が保存されるという仕様にします。
本番コード▼
class ProfileViewModel : ViewModel() {
    private val _name = MutableStateFlow("")
    val name: StateFlow<String> = _name
    private val _saveMessage = MutableStateFlow<String?>(null)
    val saveMessage: StateFlow<String?> = _saveMessage
    fun updateName(newName: String) {
        _name.value = newName
    }
    fun saveProfile() {
        if (_name.value.isBlank()) {
            _saveMessage.value = "名前を入力してください"
        } else {
            _saveMessage.value = "保存しました!"
        }
    }
}このとき、テストしたい点としては以下です。
| テストケース | 条件(事前操作) | 期待効果 | 
|---|---|---|
| 初期状態ではメッセージがnullである | 特になし(ViewModel生成直後) | saveMessage.value == null | 
| 名前を更新できる | updateName("Taro")を呼ぶ | name.value == “Taro” | 
| 名前が空のとき保存するとエラーメッセージが出る | updateName("")後にsaveProfile()を呼ぶ | saveMessage.value == “名前を入力してください” | 
| 名前が入力されているとき保存すると成功メッセージが出る | updateName("Taro")後にsaveProfile()を呼ぶ | saveMessage.value == “保存しました!” | 
これをテストコードへ落としていきます。
テストコード▼
class ProfileViewModelTest {
    private val viewModel = ProfileViewModel()
    @Test
    fun `初期状態ではメッセージがnullである`() = runTest {
        assertNull(viewModel.saveMessage.value)
    }
    @Test
    fun `名前を更新できる`() = runTest {
        viewModel.updateName("Taro")
        assertEquals("Taro", viewModel.name.value)
    }
    @Test
    fun `名前が空のとき保存するとエラーメッセージが出る`() = runTest {
        viewModel.updateName("")
        viewModel.saveProfile()
        assertEquals("名前を入力してください", viewModel.saveMessage.value)
    }
    @Test
    fun `名前が入力されているとき保存すると成功メッセージが出る`() = runTest {
        viewModel.updateName("Taro")
        viewModel.saveProfile()
        assertEquals("保存しました!", viewModel.saveMessage.value)
    }
}runTest { ... }はKotlinのコルーチン(非同期処理)をテストするための関数です。
(kotlinx-coroutines-test ライブラリが提供している関数です)
suspend関数を普通の関数のように安全にテストできるという特徴があります。
build.gradleに以下の依存を追加することで使用できるようになります。
dependencies {
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}データ取得・保存を管理するRepositoryのテスト
例として、idをもとにユーザー情報を取得する処理のテストを行います。
本番コード▼
class UserRepository {
    fun fetchUserName(userId: Int): String {
        if (userId <= 0) throw IllegalArgumentException("Invalid user ID")
        return "User$userId"
    }
}このとき、テストしたい点としては以下です。
| テストケース | 入力 | 期待効果 | 
|---|---|---|
| 正常系 – userId=1なら正しいユーザー名を返す | 1 | "User1"を返す | 
| 正常系 – userIdが大きい数でも正しいユーザー名を返す | 100など、大きい整数 | "User100"を返す | 
| 異常系 – userId=0なら例外が発生する | 0 | IllegalArgumentExceptionを投げる | 
| 異常系 – userIdが負の数なら例外が発生する | -1など負の数 | IllegalArgumentExceptionを投げる | 
これをテストコードへ落としていきます。
テストコード▼
class UserRepositoryTest {
    private val repository = UserRepository()
    @Test
    fun `正常系 - userIdが1ならUser1を返す`() {
        val name = repository.fetchUserName(1)
        assertEquals("User1", name)
    }
    @Test
    fun `正常系 - userIdが100ならUser100を返す`() {
        val name = repository.fetchUserName(100)
        assertEquals("User100", name)
    }
    @Test
    fun `異常系 - userIdが0ならIllegalArgumentExceptionが発生する`() {
        assertThrows(IllegalArgumentException::class.java) {
            repository.fetchUserName(0)
        }
    }
    @Test
    fun `異常系 - userIdが負の数ならIllegalArgumentExceptionが発生する`() {
        assertThrows(IllegalArgumentException::class.java) {
            repository.fetchUserName(-5)
        }
    }
}
assertThrowsは、「このコードを実行したら、きちんと例外(エラー)が発生するか」をテストする関数です。
引数にassertThrows(発生してほしい例外の型)を書きます。
この場合、例外が発生しなかったらテストは失敗します。
また、assertThrows は、発生した例外オブジェクトを取得できます。
val exception = assertThrows(IllegalArgumentException::class.java) {
    repository.fetchUserName(0)
}
assertEquals("Invalid user ID", exception.message)このように、「本当に期待するメッセージが出ているか」までチェックすると良いでしょう。
まとめ
おつかれさまでした。いかがでしたでしょうか!
テストコードを書くときは、最後にまとめて書くのではなく
実装段階でこまめに書きながら進めるのがおすすめです。
JUnit5を使えば、手動で行うテストの手間を大幅に省くことができるので
ぜひ開発に取り入れてみてください!



技術者としてのキャリアパスを次のレベルへと進めたい皆様、
未経験からIT・Webエンジニアを目指すなら【ユニゾンキャリア】

自分の市場価値をさらに向上させてみませんか?
それではまた次の記事でお会いしましょう!




 
			







コメント