1
2

【初心者向け】個人開発しているAndroidアプリでCIを回してみた【種族値クイズ】

Posted at

はじめに

最近、自分の個人開発をしているプロジェクトでCIを回せるようにしました。
リリースしたアプリがこちらです。ポケモン好きな人にはぜひ使ってみて欲しいです
最近100インストールを超えました!🎉 嬉しい。。

CIを回せるようにしようと思った経緯や学びについてまとめておこうと思います。

この記事の対象者

  • CI/CDに興味がある人
  • アプリ開発初心者

CIとは

CIとは、Continuous Integrationの略で、日本語では継続的インテグレーションと言われます。
主に開発の中で、静的解析、ビルド、テストなどの工程を自動化することを指します。
また、CD(継続的デプロイ)と合わせてCI/CDとして使われることも多いです。
CDも名前の通り、本番環境やテスト環境へのデプロイを自動化することです。

CI/CDを実現することで、品質の確保とリリースサイクルにかかる時間の削減ができるようになるので、動くプロダクトを小さく作る上で非常に役に立ちます。

CIを回そうと思ったきっかけ

私が作っている種族値クイズのアプリは、今年の4月くらいにリリースしました。

当時は早くリリースしたかったので、テストは後回しにしていました。
ですが、その後コードのリファクタリングをしたり、新機能を開発する上で「どこの機能もちゃんと動いてるかな?」という確認がだんだん面倒になってきました。

私はこのアプリをこれからもどんどん拡張していこうと思っているので、確認しにくい不具合で重要なバグを見落とさないように、CIを実践してみることにしました。

とりあえずは1人開発でやっていて、テスターへアプリを配布するといったことがないので、CDの整備は後回しにしています。

まずはテストコードを作成する

とにもかくにも、テストコードがないことにCIは回せません。
(※ビルドとか静的解析だけ入れることはできますが、この場合あんまり効果がないのでテストコードを入れます)

今回はUI部分の表示をテストしていきます。
QuizUiStateでUI表示の状態変更を行っているので、そこで使っている関数を全てテストできるようにします。

QuizUiState.kt
data class QuizUiState(
    val pokemonList: List<PokemonModel> = emptyList(),
    val currentIndex: Int = 0,
    val isAnswered: Boolean = false,
    val isCorrect: Boolean = false,
    val isGiveUp: Boolean = false,
    val userInput: String = "",
    val showBaseStats: Boolean = true,
    val result: Result = Result.LOADING,
    val keyboardEnabled: Boolean = true,
    val answerButtonEnabled: Boolean = true,
    val nextButtonEnabled: Boolean = true,
    val giveUpButtonEnabled: Boolean = true,
    val userAnswerCount: Int = 0,
    val errorMessage: String? = null,
    val generationButtonText: String = "ヒント\n地方",
    val typeButtonText: String = "ヒント\nタイプ",
    val abilityButtonText: String = "ヒント\nとくせい"
) {
    fun proceedToNextQuestion(): QuizUiState {
        return copy(
            currentIndex = currentIndex + 1,
            isAnswered = false,
            isCorrect = false,
            isGiveUp = false,
            userInput = "",
            showBaseStats = true,
            generationButtonText = "ヒント\n地方",
            typeButtonText = "ヒント\nタイプ",
            abilityButtonText = "ヒント\nとくせい",
            keyboardEnabled = true,
            answerButtonEnabled = true,
            nextButtonEnabled = true,
            giveUpButtonEnabled = true
        )
    }

    fun giveUp(): QuizUiState {
        return copy(
            isGiveUp = true,
            showBaseStats = false,
            keyboardEnabled = false,
            answerButtonEnabled = false,
            giveUpButtonEnabled = false
        )
    }

    fun checkAnswer(userInput: String): QuizUiState {
        val currentPokemon = pokemonList[currentIndex]
        val isCorrect = userInput == currentPokemon.name
        return copy(
            isAnswered = true,
            isCorrect = isCorrect,
            showBaseStats = !isCorrect,
            keyboardEnabled = !isCorrect,
            answerButtonEnabled = !isCorrect,
            giveUpButtonEnabled = !isCorrect,
            userAnswerCount = userAnswerCount + 1
        )
    }

    fun toggleGenerationButton(): QuizUiState {
        val currentPokemon = pokemonList[currentIndex]
        return copy(
            generationButtonText = currentPokemon.generation.regionNameInJapanese
        )
    }

    fun toggleTypeButton(): QuizUiState {
        val currentPokemon = pokemonList[currentIndex]
        val type1 = currentPokemon.types.type1
        val type2 = currentPokemon.types.type2
        return copy(
            typeButtonText = if (type2?.isNotBlank() == true) {
                "$type1\n$type2"
            } else {
                type1
            }
        )
    }

    fun toggleAbilityButton(): QuizUiState {
        val currentPokemon = pokemonList[currentIndex]
        val ability1 = currentPokemon.abilities.ability1
        val ability2 = currentPokemon.abilities.ability2
        val ability3 = currentPokemon.abilities.ability3
        val abilities = listOf(
            ability1,
            ability2,
            ability3
        ).filterNot { it.isNullOrEmpty() }
        return copy(
            abilityButtonText = abilities.joinToString("\n")
        )
    }
}
QuizUiStateTest.kt
@Suppress("NonAsciiCharacters", "TestFunctionName")
class QuizUiStateTest {
    @Test
    fun 次の問題に進むと状態が適切に変更される() {
        // Arrange
        val quizUiState = QuizUiState(
            pokemonList = listOf(
                PokemonModel(
                    name = "ピカチュウ",
                    stats = StatModel(
                        hp = 35,
                        attack = 55,
                        defense = 40,
                        specialAttack = 50,
                        specialDefense = 50,
                        speed = 90
                    ),
                    imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
                    generation = Generation.KANTO,
                    abilities = AbilityModel(
                        ability1 = "せいでんき",
                        ability2 = "(夢)ひらいしん",
                        ability3 = ""
                    ),
                    types = TypeModel(
                        type1 = "でんき",
                        type2 = ""
                    )
                )
            )
        )

        // Assert
        assertEquals(quizUiState.proceedToNextQuestion().isAnswered, false, "回答済み状態になっていない")
        assertEquals(quizUiState.proceedToNextQuestion().isCorrect, false, "正解状態になっていない")
        assertEquals(quizUiState.proceedToNextQuestion().isGiveUp, false, "ギブアップ状態になっていない")
        assertEquals(quizUiState.proceedToNextQuestion().userInput, "", "ユーザーの入力がリセットされている")
        assertEquals(quizUiState.proceedToNextQuestion().showBaseStats, true, "ステータスが表示されていない")
        assertEquals(quizUiState.proceedToNextQuestion().generationButtonText, "ヒント\n地方", "世代のヒントが初期値になっている")
        assertEquals(quizUiState.proceedToNextQuestion().typeButtonText, "ヒント\nタイプ", "タイプのヒントが初期値になっている")
        assertEquals(quizUiState.proceedToNextQuestion().abilityButtonText, "ヒント\nとくせい", "特性のヒントが初期値になっている")
        assertEquals(quizUiState.proceedToNextQuestion().keyboardEnabled, true, "キーボードが有効化されている")
        assertEquals(quizUiState.proceedToNextQuestion().answerButtonEnabled, true, "回答ボタンが有効化されている")
        assertEquals(quizUiState.proceedToNextQuestion().giveUpButtonEnabled, true, "ギブアップボタンが有効化されている")
        assertEquals(quizUiState.nextButtonEnabled, true, "次の問題ボタンが有効化されている")
    }

    @Test
    fun ギブアップした後に状態が適切に変更される() {
        // Arrange
        val quizUiState = QuizUiState()

        // Assert
        assertEquals(quizUiState.giveUp().isGiveUp, true, "ギブアップ状態になっている")
        assertEquals(quizUiState.giveUp().showBaseStats, false, "ステータスが表示されていない")
        assertEquals(quizUiState.giveUp().keyboardEnabled, false, "キーボードが無効化されている")
        assertEquals(quizUiState.giveUp().answerButtonEnabled, false, "回答ボタンが無効化されている")
        assertEquals(quizUiState.giveUp().giveUpButtonEnabled, false, "ギブアップボタンが無効化されている")
    }

    @Test
    fun 回答をチェックした後に状態が適切に変更される() {
        // Arrange
        val quizUiState = QuizUiState(
            pokemonList = listOf(
                PokemonModel(
                    name = "ピカチュウ",
                    stats = StatModel(
                        hp = 35,
                        attack = 55,
                        defense = 40,
                        specialAttack = 50,
                        specialDefense = 50,
                        speed = 90
                    ),
                    imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
                    generation = Generation.KANTO,
                    abilities = AbilityModel(
                        ability1 = "せいでんき",
                        ability2 = "(夢)ひらいしん",
                        ability3 = ""
                    ),
                    types = TypeModel(
                        type1 = "でんき",
                        type2 = ""
                    )
                )
            )
        )

        // Assert
        assertEquals(quizUiState.checkAnswer("ピカチュウ").isAnswered, true, "回答済み状態になっている")
        assertEquals(quizUiState.checkAnswer("ピカチュウ").isCorrect, true, "正解状態になっている")
        assertEquals(quizUiState.checkAnswer("ピカチュウ").showBaseStats, false, "ステータスが表示されていない")
        assertEquals(quizUiState.checkAnswer("ピカチュウ").keyboardEnabled, false, "キーボードが無効化されている")
        assertEquals(quizUiState.checkAnswer("ピカチュウ").answerButtonEnabled, false, "回答ボタンが無効化されている")
    }

    @Test
    fun ボタンを押して世代のヒントが表示される() {
        // Arrange
        val quizUiState = QuizUiState(
            pokemonList = listOf(
                PokemonModel(
                    name = "ピカチュウ",
                    stats = StatModel(
                        hp = 35,
                        attack = 55,
                        defense = 40,
                        specialAttack = 50,
                        specialDefense = 50,
                        speed = 90
                    ),
                    imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
                    generation = Generation.KANTO,
                    abilities = AbilityModel(
                        ability1 = "せいでんき",
                        ability2 = "(夢)ひらいしん",
                        ability3 = ""
                    ),
                    types = TypeModel(
                        type1 = "でんき",
                        type2 = ""
                    )
                )
            )
        )

        // Assert
        assertEquals(quizUiState.toggleGenerationButton().generationButtonText, "カントー")
    }

    @Test
    fun ボタンを押してタイプのヒントが表示される() {
        // Arrange
        val quizUiState = QuizUiState(
            pokemonList = listOf(
                PokemonModel(
                    name = "ピカチュウ",
                    stats = StatModel(
                        hp = 35,
                        attack = 55,
                        defense = 40,
                        specialAttack = 50,
                        specialDefense = 50,
                        speed = 90
                    ),
                    imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
                    generation = Generation.KANTO,
                    abilities = AbilityModel(
                        ability1 = "せいでんき",
                        ability2 = "(夢)ひらいしん",
                        ability3 = ""
                    ),
                    types = TypeModel(
                        type1 = "でんき",
                        type2 = ""
                    )
                )
            )
        )

        // Assert
        assertEquals(quizUiState.toggleTypeButton().typeButtonText, "でんき")
    }

    @Test
    fun ボタンを押して特性のヒントが表示される() {
        // Arrange
        val quizUiState = QuizUiState(
            pokemonList = listOf(
                PokemonModel(
                    name = "ピカチュウ",
                    stats = StatModel(
                        hp = 35,
                        attack = 55,
                        defense = 40,
                        specialAttack = 50,
                        specialDefense = 50,
                        speed = 90
                    ),
                    imageUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
                    generation = Generation.KANTO,
                    abilities = AbilityModel(
                        ability1 = "せいでんき",
                        ability2 = "(夢)ひらいしん",
                        ability3 = ""
                    ),
                    types = TypeModel(
                        type1 = "でんき",
                        type2 = ""
                    )
                )
            )
        )

        // Assert
        assertEquals(quizUiState.toggleAbilityButton().abilityButtonText, "せいでんき\n(夢)ひらいしん")
    }
}

これでクイズ画面の状態変更をテストできるようになりました。
例えば、コードのリファクタリングを行った際にうっかり処理の中身が変わってしまった場合、テストが実行されることで気付くことができます。

使用するCIツール

CIツールはいろいろありますが、GitHub Actionsを使用します。理由は以下の通りです。

  • ユーザー数や情報が多い
  • GitHub上でワークフローを設定・管理できる
  • プライベートリポジトリでも制限付き無料で使える

特に参考となる記事や公式のサポートが多く、スムーズに導入できそうだと判断してGitHub Actionsを使用することにしました。

CIを作成する

まずはymlファイルを作成し、.github/workflows/に配置します。
リポジトリ内のディレクトリ構造はこんな感じです

base-stats-quiz/
├── .github/
│   └── workflows/
│       └── ci.yml
├── app/
├── src/
└── README.md

CIの設定には基本的にymlで記述されることが多いです。
一度自分で書いてみるとすぐに読めるようになるので、とりあえず書いてみましょう。

まずはシンプルにmaindevelopに対してpushとPR時にユニットテストが実行されるようなCIを設定します。

ci.yml
name: CI

on:
 push:
   branches:
     - main
     - develop
 pull_request:
   branches:
     - main
     - develop

jobs:
 build:
   runs-on: ubuntu-latest

   steps:
     - name: Checkout code
       uses: actions/checkout@v3

     - name: Set up JDK 17
       uses: actions/setup-java@v3
       with:
         distribution: 'zulu'
         java-version: '17'

     - name: Cache Gradle packages
       uses: actions/cache@v3
       with:
         path: ~/.gradle/caches
         key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
         restore-keys: |
           ${{ runner.os }}-gradle-

     - name: Build the project
       run: ./gradlew assembleDebug

 test:
   runs-on: ubuntu-latest
   steps:
     - name: Checkout code
       uses: actions/checkout@v3

     - name: Set up JDK 17
       uses: actions/setup-java@v3
       with:
         distribution: 'zulu'
         java-version: '17'

     - name: Cache Gradle packages
       uses: actions/cache@v3
       with:
         path: ~/.gradle/caches
         key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
         restore-keys: |
           ${{ runner.os }}-gradle-

     - name: Run unit tests
       run: ./gradlew testDebugUnitTest --stacktrace

     - name: Upload unit test results
       uses: actions/upload-artifact@v3
       if: success() || failure()
       with:
         name: results
         path: |
           **/build/reports/tests/**/*
           **/build/reports/test-results/**/*
         if-no-files-found: warn
         retention-days: 7

ci.ymlがCI設定用ファイルの全体像です。部分的に解説していきますが、
その前に基本を軽く説明しておきます

  • jobとは
    • ワークフロー内の一連のタスク(step)をグループ化したもの
  • stepとは
    • jobで実行されるそれぞれのタスク。例えばビルドやユニットテストを実行したり、テストのレポートをアップロードしたりするアクションのこと
  • ランナーとは
    • GitHubホステッドランナー
      • GitHub Actionsのワークフローを実行するために、GitHubが提供しているサーバー環境のこと。今回はこっちを使う。
    • セルフホステッドランナー
      • 自分で管理するサーバーをランナーとして設定することもできる
イベントトリガーを決める.yml
on:
 push:
   branches:
     - main
     - develop
 pull_request:
   branches:
     - main
     - develop

on: で特定のブランチに対してアクションをトリガーする条件を示すことができます。
maindevelopにpushされた時と、maindevelopに対してプルリクエストが作成された時にアクションがトリガーされ、後述するjobsで定義された内容が実行されます。

ジョブを定義する.yml
jobs:
 build:
   runs-on: ubuntu-latest

   steps:
     - name: Checkout code
       uses: actions/checkout@v3

     - name: Set up JDK 17
       uses: actions/setup-java@v3
       with:
         distribution: 'zulu'
         java-version: '17'

     - name: Cache Gradle packages
       uses: actions/cache@v3
       with:
         path: ~/.gradle/caches
         key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
         restore-keys: |
           ${{ runner.os }}-gradle-

     - name: Build the project
       run: ./gradlew assembleDebug

 test:
   runs-on: ubuntu-latest
   steps:
     - name: Checkout code
       uses: actions/checkout@v3

     - name: Set up JDK 17
       uses: actions/setup-java@v3
       with:
         distribution: 'zulu'
         java-version: '17'

     - name: Cache Gradle packages
       uses: actions/cache@v3
       with:
         path: ~/.gradle/caches
         key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
         restore-keys: |
           ${{ runner.os }}-gradle-

     - name: Run unit tests
       run: ./gradlew testDebugUnitTest --stacktrace

     - name: Upload unit test results
       uses: actions/upload-artifact@v3
       if: success() || failure()
       with:
         name: results
         path: |
           **/build/reports/tests/**/*
           **/build/reports/test-results/**/*
         if-no-files-found: warn
         retention-days: 7

jobsで複数のジョブを定義できます。
ここでは、buildとtestがジョブにあたります。

build.yml
build:
   runs-on: ubuntu-latest

   steps:
     - name: Checkout code
       uses: actions/checkout@v3

     - name: Set up JDK 17
       uses: actions/setup-java@v3
       with:
         distribution: 'zulu'
         java-version: '17'

     - name: Cache Gradle packages
       uses: actions/cache@v3
       with:
         path: ~/.gradle/caches
         key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
         restore-keys: |
           ${{ runner.os }}-gradle-

     - name: Build the project
       run: ./gradlew assembleDebug

GitHub Actionsが提供するランナー(仮想マシン)上で、アプリをビルドします。
以下の流れでステップが実行されます。

  1. runs-on: ubuntu-latestでジョブがどの環境(ランナー)で実行されるかを指定します。これはジョブ全体の設定なのでステップではありません。
  2. name: Checkout codeと命名したステップで、actions/checkout@v3を実行します。チェックアウトと言いつつ、実際にやってるのはリポジトリの特定のコミットのクローンです。v3っていうのはaction/checkoutのバージョンのことです。2023年9月にはv4がリリースされたらしい。
  3. Set up JDK 17と命名したステップで、actions/setup-java@v3を実行します。ランナー環境にZulu JDK17をインストールして、JAVA_HOME環境変数を設定します。
  4. name: Cache Gradle packagesと命名したステップで、actions/cache@v3を実行してます。pathで指定したパスにあるデータをキャッシュして、keyでどのキャッシュを使うのか識別します。restore-keysでは、keyで指定するキャッシュが見つからなかった場合に使用する予備のキャッシュを指定します。
  5. Build the projectと命名したステップで、/gradlew assembleDebugを実行しています。これでデバッグビルドが作成されます。
test.yml
test:
   runs-on: ubuntu-latest
   steps:
     - name: Checkout code
       uses: actions/checkout@v3

     - name: Set up JDK 17
       uses: actions/setup-java@v3
       with:
         distribution: 'zulu'
         java-version: '17'

     - name: Cache Gradle packages
       uses: actions/cache@v3
       with:
         path: ~/.gradle/caches
         key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
         restore-keys: |
           ${{ runner.os }}-gradle-

     - name: Run unit tests
       run: ./gradlew testDebugUnitTest --stacktrace

     - name: Upload unit test results
       uses: actions/upload-artifact@v3
       if: success() || failure()
       with:
         name: results
         path: |
           **/build/reports/tests/**/*
           **/build/reports/test-results/**/*
         if-no-files-found: warn
         retention-days: 7

ユニットテストを実行します。
ランナーの設定からキャッシュの設定まではbuildの時と同じです。以降のステップを解説します。

  1. RUn unit testsと命名したステップで、./gradlew testDebugUnitTest --stacktraceを実行します。stacktraceをつけることでエラーの詳細情報を取得することができます。
  2. Upload unit test resultsと命名したステップで、actions/upload-artifact@v3を実行しています。このステップでは、アーティファクトと呼ばれるファイルをGitHub上にアップロードします。アーティファクトはpathで指定されたディレクトリにあるビルドされたファイルやログファイルなどであり、ここではテストレポートがアーティファクトにあたります。成功した時と失敗した時にも実行されるようにifで指定して、retention-daysで保存される期間を指定しています。

このci.ymlが含まれた状態でmaindevelopへのPR、もしくはmaindevelopにプッシュされると、GitHub上でテストが実行されるようになりました。

詰まったところ

なぜかgradle-wrapperが削除されてしまっていて、インストールし直しました。

こんな感じでエラーが出ました
Could not find or load main class org.gradle.wrapper.GradleWrapperMain

なかなか気付けず無駄に時間を浪費してしまいましたが、CIの実行を整えるにあたってgradleのことを勉強し直すことができたので、そこは良かったと思います。

感想と今後の見通し

基本的な開発知識さえあれば、CI環境を整えること自体は難しくないと感じました。

今後は他の主要なビジネスロジックにもテストコードを追加していきたいですね。
また、テストカバレッジの計測にも取り組むことで、品質と開発速度を上げていこうと思います。

ここまで読んでいただきありがとうございました!

参考

1
2
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
1
2