今回は直近のAndroidチームの品質改善の取り組みとして、ユニットテストを導入した件についてお話したいと思います。
はじめに
Android版Safie Viewer for Mobileは開発が始まって3年が経過しようとしています。 3年間、継ぎ足しを重ねて作成してきたコードはもうじき5万行を超える大きさになりました。
Androidチームは直近までプロジェクトのメンバーが少なく、機能を実装してリリースする事で精一杯になっていました。 なんとかリリースまで辿り着けているものの、目の前には日に日に複雑になっていくコードがあるというのが現実でコードのリファクタリングまで手が回らずメンテナンスする事ができない状況に陥っていました。
この状況はAndroidチームとしても今後の課題となって行く事が目に見えていたため リファクタリングの前段階として、既存コードが設計通りに動くかの保証を目的としたユニットテストを導入する事にしました。
実際にやった事
主な取り組みとして以下の3点を行いました
- ユニットテストを書く
- カバレッジ率の可視化
- CIで自動でテストが実行される環境構築
ユニットテストを書く
どこからユニットテストを書くか
Android版Safie Viewer for Mobileのアーキテクチャは公式が推奨しているような
- UI Layer
- Domain Layer
- Data Layer
の3層に分け、責務を分離する事を心がけ作られています。 幸いにもこのルールが守られている事もあってか、レイヤ間が疎結合にはなっておりユニットテストが書ける土壌にはありました。
しかしながら、土壌はあったもののAndroidチームとしてこれまでユニットテストを書くという文化が無かったので いきなりActivity/FragmentやViewModelなどの画面に近い箇所のテストコードを書くのはハードルが高いという事もあり 画面から遠く機能としても孤立しているData Layerからテストを書き始め、徐々に上のレイヤーに向かっていくという方針にしました。
技術スタック
今回、ユニットテストを書くために導入した技術スタックは主に以下になります。
- JUnit4
Androidのプロジェクト作成時に既に導入されているテストフレームワーク - Robolectric
Android依存部分のテストを行うためのライブラリ - Mockito-Kotlin
モックライブラリMockitoをKotlinでも使いやすくしたライブラリ - Truth
アサーションライブラリ - Jacoco
テストカバー率の測定
JUnit4 + Mockito-Kotlin + Truthを使用したテストコード
記述例として、下記のようなAndroidに依存しないData Layerのクラスを用意してテストコードを書きます。
package com.safie.test.data import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext interface EmployeeService { /**従業員IDの最大値を取得*/ fun getMaxId(): Int } class EmployeeRepository( private val service: EmployeeService, private val dispatcher: CoroutineDispatcher ) { /** * IDの最大値にプラス1した、新しい従業員IDを取得 * @return 従業員ID */ suspend fun getNewId() = withContext(dispatcher) { val maxId = service.getMaxId() return@withContext maxId + 1 } }
上記のようなクラスに対しては次のようなテストコードを書くことができます。
import com.google.common.truth.Truth import com.safie.test.data.EmployeeRepository import com.safie.test.data.EmployeeService import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class EmployeeRepositoryTest { @Test fun testGetNewId() = runTest { val mockEmployeeService: EmployeeService = mock() val dispatcher = StandardTestDispatcher(testScheduler) val employeeRepository = EmployeeRepository(mockEmployeeService, dispatcher) //getMaxId()を実行した時、10を返すように設定 val mockMaxId = 10 whenever(mockEmployeeService.getMaxId()).thenReturn(mockMaxId) //maxIdにプラス1した値が返却される事を検証 val id = employeeRepository.getNewId() Truth.assertThat(id).isEqualTo(mockMaxId + 1) } }
カバレッジ率の可視化
ユニットテストを書き続けるというモチベーションを維持するのはとても大変です。
そのモチベーションを維持する取り組みとして
- テストコードによってどの行が実行されたか
- 各クラスのカバレッジ率は何%か
の計測結果をレポートとして出力するためにJacocoの導入をしました。
Jacocoの設定
以下のQiitaの記事を参考にJacocoの設定を行いました。
Android開発のテストカバー率取得にはこのツールを使い分けると良いという話 #Android - Qiita
apply plugin: 'jacoco' jacoco { toolVersion = "0.8.9" } android.applicationVariants.all { variant -> def variantName = variant.name.capitalize() //ex. ProdDebug def realVariantName = variant.name //ex. prodDebug if (variant.buildType.name != "debug") { return } task("jacoco${variantName}TestReport", type: JacocoReport) { dependsOn "test${variantName}UnitTest" group = "testing" description = "Generate Jacoco coverage reports for ${realVariantName}" reports { xml.required = false html.required = true } //無視するファイル(excludes)の設定を行います def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', 'android/**/*.*', 'androidx/**/*.*', '**/Lambda$*.class', '**/Lambda.class', '**/*Lambda.class', '**/*Lambda*.class', '**/*Lambda*.*', '**/*Builder.*', '**/*Activity*.class', '**/*Fragment*.class' ] def javaDebugTree = fileTree(dir: "${buildDir}/intermediates/javac/${realVariantName}/compile${variantName}JavaWithJavac/classes", excludes: fileFilter) def kotlinDebugTree = fileTree(dir: "${buildDir}/tmp/kotlin-classes/${realVariantName}", excludes: fileFilter) def mainSrc = "${project.projectDir}/src/main/java" getSourceDirectories().setFrom(files([mainSrc])) //Java, Kotlin混在ファイル対応 getClassDirectories().setFrom(files([javaDebugTree, kotlinDebugTree])) getExecutionData().setFrom(fileTree(dir: project.projectDir, includes: [ '**/*.exec', //JUnit Test Result '**/*.ec']) //Espresso Test Result ) } }
Jacocoの出力
設定したJacocoのタスクを実行することで以下のようなHTMLのレポートが出力され
- テストコードによってどの行が実行されたか
- 各クラスのカバレッジ率は何%か
を確認をすることができます。
CIで自動でテストが実行される環境
Android版Safie Viewer for MobileではCIにGithub Actionsを採用しております。
PullRequestのイベントをトリガーにテストを自動で実行し、テストの実行結果と現在のカバレッジ率を表示するGithub Actionsのワークフローを用意しました。
導入したアクション
ユニットテストの結果とカバレッジ率を表示する為に導入したアクションは以下になります。
- action-junit-report
Junitで出力したテスト結果のレポートを表示するワークフロー - jacoco-reporter
Jacocoで出力されたXMLをインプットに結果を表示するワークフロー
ワークフロー
今回の取り組みで実際に用意したワークフローは以下の通りです。
name: PullRequest Tasks on: pull_request: paths: - 'app/**' branches: - main jobs: UnitTestJob: name: Android Staging Unit Test runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v3 - name: set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' cache: gradle - name: Make gradlew executable run: chmod +x ./gradlew - name: run UnitTest StagingDebug run: ./gradlew jacocoStagingDebugTestReport - name: JaCoCo Code Coverage Report id: jacoco_reporter uses: PavanMudigonda/jacoco-reporter@v4.8 with: coverage_results_path: ${{ github.workspace }}/app/build/reports/jacoco/jacocoStagingDebugTestReport/jacocoStagingDebugTestReport.xml coverage_report_name: Coverage coverage_report_title: JaCoCo github_token: ${{ secrets.GITHUB_TOKEN }} skip_check_run: false minimum_coverage: 40 fail_below_threshold: false publish_only_summary: false - name: Add Coverage Job Summary run: echo "${{ steps.jacoco_reporter.outputs.coverageSummary }}" >> $GITHUB_STEP_SUMMARY - name: Publish Test Report uses: mikepenz/action-junit-report@v3 if: cancelled() != true with: report_paths: '**/build/test-results/*/TEST-*.xml'
このワークフローが実行されるとPullRequest上でテストの結果や現在のカバレッジ率を確認することができるようになります。
最後に
今回行った取り組みは
- ユニットテストを書く
- カバレッジ率の可視化
- CIで自動でテストが実行される環境構築
だけの最低限の環境を作っただけにすぎず、これだけではチームにユニットテストを書いていく文化が広がっていきません。 しかしながら、ユニットテストを作る環境を導入した事は開発チームにとって重要な足がかりとなります。 今後は、チーム全体でテストの重要性を認識しつつユニットテストを書く事が当たり前の文化を作っていく取り組みをしていきます。
また、セーフィーではエンジニアの採用を積極的に行っております。もし興味が出てきた際はぜひご応募いただけたらと思います。