はじめに
最近、自分の個人開発をしているプロジェクトでCIを回せるようにしました。
リリースしたアプリがこちらです。ポケモン好きな人にはぜひ使ってみて欲しいです
最近100インストールを超えました!🎉 嬉しい。。
CIを回せるようにしようと思った経緯や学びについてまとめておこうと思います。
この記事の対象者
- CI/CDに興味がある人
- アプリ開発初心者
CIとは
CIとは、Continuous Integrationの略で、日本語では継続的インテグレーションと言われます。
主に開発の中で、静的解析、ビルド、テストなどの工程を自動化することを指します。
また、CD(継続的デプロイ)と合わせてCI/CDとして使われることも多いです。
CDも名前の通り、本番環境やテスト環境へのデプロイを自動化することです。
CI/CDを実現することで、品質の確保とリリースサイクルにかかる時間の削減ができるようになるので、動くプロダクトを小さく作る上で非常に役に立ちます。
CIを回そうと思ったきっかけ
私が作っている種族値クイズのアプリは、今年の4月くらいにリリースしました。
当時は早くリリースしたかったので、テストは後回しにしていました。
ですが、その後コードのリファクタリングをしたり、新機能を開発する上で「どこの機能もちゃんと動いてるかな?」という確認がだんだん面倒になってきました。
私はこのアプリをこれからもどんどん拡張していこうと思っているので、確認しにくい不具合で重要なバグを見落とさないように、CIを実践してみることにしました。
とりあえずは1人開発でやっていて、テスターへアプリを配布するといったことがないので、CDの整備は後回しにしています。
まずはテストコードを作成する
とにもかくにも、テストコードがないことにCIは回せません。
(※ビルドとか静的解析だけ入れることはできますが、この場合あんまり効果がないのでテストコードを入れます)
今回はUI部分の表示をテストしていきます。
QuizUiStateでUI表示の状態変更を行っているので、そこで使っている関数を全てテストできるようにします。
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")
)
}
}
@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で記述されることが多いです。
一度自分で書いてみるとすぐに読めるようになるので、とりあえず書いてみましょう。
まずはシンプルにmain
とdevelop
に対してpushとPR時にユニットテストが実行されるようなCIを設定します。
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が提供しているサーバー環境のこと。今回はこっちを使う。
- セルフホステッドランナー
- 自分で管理するサーバーをランナーとして設定することもできる
- GitHubホステッドランナー
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
on:
で特定のブランチに対してアクションをトリガーする条件を示すことができます。
main
とdevelop
にpushされた時と、main
とdevelop
に対してプルリクエストが作成された時にアクションがトリガーされ、後述するjobs
で定義された内容が実行されます。
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:
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が提供するランナー(仮想マシン)上で、アプリをビルドします。
以下の流れでステップが実行されます。
-
runs-on: ubuntu-latest
でジョブがどの環境(ランナー)で実行されるかを指定します。これはジョブ全体の設定なのでステップではありません。 -
name: Checkout code
と命名したステップで、actions/checkout@v3
を実行します。チェックアウトと言いつつ、実際にやってるのはリポジトリの特定のコミットのクローンです。v3っていうのはaction/checkoutのバージョンのことです。2023年9月にはv4がリリースされたらしい。 -
Set up JDK 17
と命名したステップで、actions/setup-java@v3
を実行します。ランナー環境にZulu JDK17をインストールして、JAVA_HOME
環境変数を設定します。 -
name: Cache Gradle packages
と命名したステップで、actions/cache@v3
を実行してます。path
で指定したパスにあるデータをキャッシュして、key
でどのキャッシュを使うのか識別します。restore-keys
では、key
で指定するキャッシュが見つからなかった場合に使用する予備のキャッシュを指定します。 -
Build the project
と命名したステップで、/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
ユニットテストを実行します。
ランナーの設定からキャッシュの設定まではbuildの時と同じです。以降のステップを解説します。
-
RUn unit tests
と命名したステップで、./gradlew testDebugUnitTest --stacktrace
を実行します。stacktraceをつけることでエラーの詳細情報を取得することができます。 -
Upload unit test results
と命名したステップで、actions/upload-artifact@v3
を実行しています。このステップでは、アーティファクトと呼ばれるファイルをGitHub上にアップロードします。アーティファクトはpath
で指定されたディレクトリにあるビルドされたファイルやログファイルなどであり、ここではテストレポートがアーティファクトにあたります。成功した時と失敗した時にも実行されるようにifで指定して、retention-days
で保存される期間を指定しています。
このci.yml
が含まれた状態でmain
かdevelop
へのPR、もしくはmain
かdevelop
にプッシュされると、GitHub上でテストが実行されるようになりました。
詰まったところ
なぜかgradle-wrapperが削除されてしまっていて、インストールし直しました。
こんな感じでエラーが出ました
Could not find or load main class org.gradle.wrapper.GradleWrapperMain
なかなか気付けず無駄に時間を浪費してしまいましたが、CIの実行を整えるにあたってgradleのことを勉強し直すことができたので、そこは良かったと思います。
感想と今後の見通し
基本的な開発知識さえあれば、CI環境を整えること自体は難しくないと感じました。
今後は他の主要なビジネスロジックにもテストコードを追加していきたいですね。
また、テストカバレッジの計測にも取り組むことで、品質と開発速度を上げていこうと思います。
ここまで読んでいただきありがとうございました!
参考