Safie Engineers' Blog!

Safieのエンジニアが書くブログです

Androidチームにおける品質改善①〜ユニットテストの導入〜

今回は直近のAndroidチームの品質改善の取り組みとして、ユニットテストを導入した件についてお話したいと思います。

はじめに

Android版Safie Viewer for Mobileは開発が始まって3年が経過しようとしています。 3年間、継ぎ足しを重ねて作成してきたコードはもうじき5万行を超える大きさになりました。

Androidチームは直近までプロジェクトのメンバーが少なく、機能を実装してリリースする事で精一杯になっていました。 なんとかリリースまで辿り着けているものの、目の前には日に日に複雑になっていくコードがあるというのが現実でコードのリファクタリングまで手が回らずメンテナンスする事ができない状況に陥っていました。

この状況はAndroidチームとしても今後の課題となって行く事が目に見えていたため リファクタリングの前段階として、既存コードが設計通りに動くかの保証を目的としたユニットテストを導入する事にしました。

実際にやった事

主な取り組みとして以下の3点を行いました

  • ユニットテストを書く
  • カバレッジ率の可視化
  • CIで自動でテストが実行される環境構築

ユニットテストを書く

どこからユニットテストを書くか

Android版Safie Viewer for Mobileのアーキテクチャは公式が推奨しているような

  1. UI Layer
  2. Domain Layer
  3. Data Layer

の3層に分け、責務を分離する事を心がけ作られています。 幸いにもこのルールが守られている事もあってか、レイヤ間が疎結合にはなっておりユニットテストが書ける土壌にはありました。

アーキテクチャの図(公式より引用)

しかしながら、土壌はあったもののAndroidチームとしてこれまでユニットテストを書くという文化が無かったので いきなりActivity/FragmentやViewModelなどの画面に近い箇所のテストコードを書くのはハードルが高いという事もあり 画面から遠く機能としても孤立しているData Layerからテストを書き始め、徐々に上のレイヤーに向かっていくという方針にしました。

技術スタック

今回、ユニットテストを書くために導入した技術スタックは主に以下になります。

  1. JUnit4
    Androidのプロジェクト作成時に既に導入されているテストフレームワーク

  2. Robolectric
    Android依存部分のテストを行うためのライブラリ

  3. Mockito-Kotlin
    モックライブラリMockitoをKotlinでも使いやすくしたライブラリ

  4. Truth
    アサーションライブラリ

  5. 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のワークフローを用意しました。

導入したアクション

ユニットテストの結果とカバレッジ率を表示する為に導入したアクションは以下になります。

  1. action-junit-report
    Junitで出力したテスト結果のレポートを表示するワークフロー
  2. 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上でテストの結果や現在のカバレッジ率を確認することができるようになります。

ワークフローの実行結果
jacoco-reporterの結果

最後に

今回行った取り組みは

  • ユニットテストを書く
  • カバレッジ率の可視化
  • CIで自動でテストが実行される環境構築

だけの最低限の環境を作っただけにすぎず、これだけではチームにユニットテストを書いていく文化が広がっていきません。 しかしながら、ユニットテストを作る環境を導入した事は開発チームにとって重要な足がかりとなります。 今後は、チーム全体でテストの重要性を認識しつつユニットテストを書く事が当たり前の文化を作っていく取り組みをしていきます。

また、セーフィーではエンジニアの採用を積極的に行っております。もし興味が出てきた際はぜひご応募いただけたらと思います。

safie.co.jp

© Safie Inc.