VRTから静的解析まで ─ GigaViewer for Apps Androidのテストをご紹介

こんにちは、Android アプリエンジニアの id:kk__777 です。『Inside GigaViewer for Apps』連載8回目は、出版社向けマンガビューワのアプリ版である「GigaViewer for Apps」(以下 GigaApps)のVRTから静的解析まで、Androidのテストについてお話しします。

GigaApps UnitTestの歴史と現在

概要

UnitTestは、現代のAndroid開発において欠かせない要素となっています。GigaAppsでも、リグレッション防止やリファクタリングの安全性向上を目的として、UnitTestを積極的に導入しています。

アーキテクチャとUnitTestの基本方針

GigaAppsでは、Jetpack Composeによる単方向データフローを前提としたアーキテクチャを採用しています。 具体的には、UIはViewModelが保持する StateFlowcollectAsState() で監視し、Stateの変更に応じて自動的に再描画される、状態駆動型の構造になっています。 ユーザー操作などのUIイベントはViewModelの関数を通じて処理され、その中でロジックの実行やApollo Kotlinを用いたGraphQLクエリを実行し、結果に応じてStateが更新され、その変更がCompose UIにリアクティブに反映される、という流れになっています。

アーキテクチャのより詳細な解説は以下をご覧ください。 developer.hatenastaff.com

また、特に GigaApps における GraphQL 活用については、id:nabe1216 が発表した以下のスライドが参考になります:
speakerdeck.com

このような構成におけるUnitTestでは、ViewModelに対して関数を呼び出し、StateFlowの内容が意図した通りに更新されるかどうかをassertするという、シンプルかつ一般的なテストスタイルを採用しています。

class ComicsViewModelTest {
    @get:Rule
    val testRule = CoroutineDispatcherTestRule()
    @get:Rule
    val expect: Expect = Expect.create()
    @MockK
    lateinit var apolloService: ApolloService
    ...

    @Before
    fun setUp() {
        MockKAnnotations.init(this, relaxed = true)
        every { apolloService.watchData(isRefresh = false) } returns flowOf(mockInitialData)
        ...
    }

    @Test
    fun 初期化時にクエリしてデータを取得する() = testRule.runTest {
        val viewModel = createViewModel()
        expect.that(viewModel.state.value.error).isNull()
        expect.that(viewModel.state.value.isLoading).isFalse()
        expect.that(viewModel.state.value.data?.topBanners).hasSize(3)
    }
    ...

}

テストスタイルとしては、いわゆる ロンドン派 に近く、外部依存をすべてモック化し、テスト対象クラスや関数の振る舞いを日本語で記述する形でテストを行っています。

使用しているツール

UnitTestで使用しているツールも、モダンなAndroid開発でよく使われるものが中心です。

モッキングライブラリ

テスト対象である ViewModel の依存をテスト用に差し替えるために、モッキングライブラリとして MockK を使用しています。

@MockK
lateinit var apolloService: ApolloService // ViewModelが依存しているGraphQL層をモック化

MockKAnnotations.init(this, relaxed = true) // @MockKアノテーションで定義したモックを初期化

every { apolloService.watchData(isRefresh = false) } returns flowOf(mockInitialData) // watchData呼び出し時に返すFlowを指定(スタブの設定)

コルーチンディスパッチャの差し替え

コルーチンのテストにおいては、Dispatchers.Main を UnconfinedTestDispatcher に差し替えるルールクラス CoroutineDispatcherTestRule を用意して使用しています。これにより、非同期処理をテスト内で制御しやすくなります。

@OptIn(ExperimentalCoroutinesApi::class)
class CoroutineDispatcherTestRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        super.finished(description)
        Dispatchers.resetMain()
    }

    fun runTest(block: suspend TestScope.() -> Unit) = runTest(context = testDispatcher, testBody = block)
}

この仕組みは Now in Android でも採用(2025/04/10 現在)されており、Android公式のテスト例でも紹介されています。

アサーション(Truth + Expect)

アサーションには、Google が提供する Truth を使用しています。Truthは assertEquals よりも読みやすく、テスト失敗時の出力が親切なのが特徴です。

expect.that(viewModel.state.value.error).isNull()
expect.that(viewModel.state.value.isLoading).isFalse()
expect.that(viewModel.state.value.data?.topBanners).hasSize(3)

失敗例。

1 expectation failed:
  1. value of    : iterable.size()
     expected    : 1
     but was     : 3
     iterable was: [TopBanner(....

テストツールの変遷

現在では、前述のように JUnit4 + MockK + Truth といった一般的な構成に落ち着いていますが、過去にはテストライブラリやフレームワークの選定に試行錯誤があり、 SpekKotest を採用していた時期もありました。 当時は、関数の振る舞いベースでテストを記述できるDSLスタイルに魅力を感じてSpekを導入していましたが、ライブラリのメンテナンス停止や、チームでの学習コストなどもあり、最終的には標準的なJUnit4ベースに回帰しています。

この変遷については、kubell様主催のkubell.mobile#2 でお話ししてますので興味のある方はぜひご覧ください:

speakerdeck.com

www.youtube.com

UIテストの取り組み

UIテストについても、RobolectricとJetpack Composeを組み合わせることで、一部の画面のUIロジックをJVM上でテストしています。

@RunWith(AndroidJUnit4::class)
class MyPageTest {
    @get:Rule
    val testRule = createAndroidComposeRule<ComponentActivity>()

    @get:Rule
    val expect: Expect = Expect.create()

    @Test
    fun 最後に選択したタブ位置の記録がないときは最初のタブが選択されている() {
        // given: 記録されている初期タブが存在しない

        /* テスト対象のViewModelとナビゲーションコントローラをモック化 */
         val viewModel: MyPageViewModel = mockk(relaxed = true)
         val screenNavController: ScreenNavController = mockk(relaxed = true)
        /* Stateのスタブ設定(タブ状態など)*/
        every { viewModel.state } returns dummyState

         // when: MyPageを描画
        testRule.setContent {
            setMyPageContentHeaderImplementation { _, _, _ -> }
            CompositionLocalProvider(
                LocalScreenNavController provides screenNavController,
            ) {
                PreviewComposable {
                    MyPage(
                        screenNavController = screenNavController,
                        tabs = mockTabs,
                        initialTabId = null,
                        viewModel = viewModel,
                    )
                }
            }
        }

        // then: 最初のタブ「お気に入り」が選択されている
        testRule.onNodeWithText("お気に入り").assertIsSelected()
        testRule.onNodeWithText("最近見た").assertIsNotSelected()
    }
}

上記は、以下のUIのタブの選択状態をテストしています。

コミックガルド+ 少年ジャンプ+

VRT(Visual Regression Testing)の導入と運用

概要

GigaAppsでは、UIの見た目の変化を検知するために VRT(Visual Regression Testing)を導入しています。特に Jetpack Compose で実装された画面の見た目を検証するために、Compose Preview Screenshot Testing を活用しています。

Compose Preview を使った VRT

Compose Preview Screenshot Testing は、Compose の @Preview アノテーションを使用したプレビューをそのままスクリーンショットテストとして利用できるツールです。既存のプレビューコードを再利用できるため、追加の実装コストを抑えられます。

具体例:

@Preview
@Composable
fun ReadButtonPreview(
    @PreviewParameter(MediaColorProvider::class) data: MediaColors
) {
    PreviewComposable(data) {
        ReadButton(onClick = { })
    }
}

GigaAppsでは Material Design を採用しており、メディアによって異なるカラースキームを定義しています。 上記の例では、PreviewParameterProvider を用いて、メディアごとに異なるカラースキーム(MediaColors)を ReadButtonPreview に注入しています。 MediaColorProviderPreviewParameterProvider を実装しており、複数のテーマやカラーパターンを用いたプレビューを自動で展開できます。 PreviewComposableは、子孫のComposableにメディア独自の情報を注入するComposableとなります。 このようにすることで、メディアA用・メディアB用などの色違いボタンを、それぞれ Preview で一括確認できるようになります。

VRTのテストコードを一元管理するために、screenshot-test モジュールを用意して src/screenshotTest ソースセットに 専用の @Composable テスト関数を用意しています。

@PreviewVrtDefaults
@Composable
fun ReadButtonPreviewTest() {
    Column(
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        MediaColorProvider().values.iterator().forEach {
            ReadButtonPreview(it)
        }
    }
}

テストの実行は以下の2つのタスクのみで行えます:

# リファレンス画像の生成
./gradlew screenshotTest:updateDebugScreenshotTest

# テストの実行
./gradlew screenshotTest:validateDebugScreenshotTest

テストの結果はHTML形式のレポートとして出力され、各カラーパターンごとのスクリーンショットと、差分画像が並べて表示されます。 差分が発生した場合、変更箇所が赤で強調表示されるため、見た目の変化を直感的に把握できます。

GigaApps特有の難しさ

UIプラグインによるPreviewの難しさ

GigaAppsでは、UIプラグインという メディアごとに中身が切り替わるComposable を導入しています。
この仕組みの詳細については、以下の記事のUIプラグインの項目をご参照ください。

様々なマンガアプリを素早く開発できる「GigaViewer for Apps」のしくみ Android 編 - UIプラグイン

以降の説明では上記記事に登場するサンプルコードを例に取ります。

このUIプラグインは、各メディアのApplicationモジュールに実体が存在しており、
呼び出し元の共通UIモジュールからは Composableの中身が参照できない という構造になっています。
そのため、通常の @Preview を使ったUIプレビューでは、以下のように意図した表示ができません。

@Preview
@Composable
fun WelcomeComponent() {
    Column {
        Text("ようこそ!")
        // ↓ 呼び出しているが、実装は別モジュールにあるため、WelcomeComponentのモジュール単体では中身が見えない
        SignUpComponent(modifier = Modifier.padding(top = 8.dp))
    }
}

UIプラグインではPreviewから呼ばれる場合、その中身を空にする仕様としていて、PreviewによるUIの正しい表示確認ができません。 また、UIプラグインの実装内容に応じて挙動も異なるため、意図したプレビューの表示ができないという課題がありました。

どう対応したか

UIプラグインは、Composableの実体を外部から動的に差し替える設計になっています。

fun setUpMediaAComponents() {
    setSignUpComponent { modifier ->
        MediaA_SignUpComponent(modifier = modifier)
    }
}

fun setUpMediaBComponent() {
    setSignUpComponent { modifier ->
        MediaB_SignUpComponent(modifier = modifier)
    }
}

そのため、UIプラグインが必要なPreviewに限り、メディアのアプリケーションモジュールに作成したscreenshotTestのソースセットから、プレビュー実行前に必要なUIを setSignUpComponent などを使って明示的に注入する形で対応しています。

// mediaA/screenshotTesting...
@PreviewVrtDefaults
@Composable
fun WelcomeComponentTest() {
    setSignUpComponent { modifier ->
        MediaA_SignUpComponent(modifier = modifier)
    }
    WelcomeComponent()
}

これにより、各メディアのスクリーンショットテストでは、Preview用のセットアップ関数内で使用したいUI実装を明示的に登録してから @Previewを呼び出す、という構成をとっています。

この仕組みは、たとえば購入シートなど、UIプラグインに依存するComposableの見た目を検証するために活用されています。

コミックガルド+ 少年ジャンプ+

CIでの運用

GitHub Actionsを使用してVRTを自動化しています。特徴は以下です:

  • コスト効率の高いビルド環境の活用

    • BuildJetの活用
    • GitHub Actionsの標準ランナーと比較して、コストパフォーマンスが優れている
  • シンプルな参照画像の管理

    • プルリクエストのベースブランチから参照画像を生成
    • 同一ジョブ内で比較対象のブランチと比較を実行
    • 外部ストレージ(S3など)での参照画像管理が不要

以下が実際のワークフロー設定の抜粋です:

name: VRT Workflow
# ... 前略 ...

jobs:
  run-vrt:
    runs-on: buildjet-8vcpu-ubuntu-2204
    outputs:
      report-url: ${{ steps.upload.outputs.artifact-url }}
    steps:
      # ... (略)環境セットアップ ...

      # base で 参照用画像取得 -> target で 比較タスク実行
      - name: Checkout base branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.base.ref }}
      # 上述した、参照用画像のタスクを実行
      - name: Run VRT update 
        run: ./gradlew ${{ inputs.module-name }}:update${{ inputs.flavor-name }}ScreenshotTest
        working-directory: android
      - name: Checkout target branch
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.ref }}
          clean: false
     # clean false によって 参照用画像を残しつつ,targetブランチでテストの実行
      - name: Run VRT validation
        run: ./gradlew ${{ inputs.module-name }}:validate${{ inputs.flavor-name }}ScreenshotTest
        working-directory: android

      # ... (略)アーティファクト生成 ...
      - name: Upload VRT report directory
        uses: actions/upload-artifact@v4

テスト結果は自動的にプルリクエストにコメントされ、テスト結果をダウンロードして確認できます

# ... 前略 ...
steps:
    - name: Comment PR
        uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
        if: ${{ github.event_name == 'pull_request' }}
        with:
        comment-tag: vrt-report
        mode: recreate
        message: |
            ## VRT レポート

            | Name | Status | Report Download URL |
            | --- | --- | --- |
            | Giga VRT     | ${{ needs.test-giga-vrt.result == 'success' && '✅' || '❌' }} ${{ needs.test-giga-vrt.result }} | [Report](${{ needs.test-giga-vrt.outputs.report-url }}) |
            | Jumpplus VRT | ${{ needs.test-jumpplus-vrt.result == 'success' && '✅' || '❌' }} ${{ needs.test-jumpplus-vrt.result }} | [Report](${{ needs.test-jumpplus-vrt.outputs.report-url }}) |
            | Gardo VRT    | ${{ needs.test-gardo-vrt.result == 'success' && '✅' || '❌' }} ${{ needs.test-gardo-vrt.result }} | [Report](${{ needs.test-gardo-vrt.outputs.report-url }}) |

静的解析

コード品質を保つために ktlint-gradle と Android Lint を活用した静的解析を実施しています。

CIによる運用

それぞれの解析結果は GitHub Actions 上で自動的にチェックされ、CI上での検知・可視化が行えるようになっています。

name: Android Lint
# ...
jobs:
  ktlint:
    # ...
    steps:
      # ... 環境設定

      # 複数モジュールのktlint結果をマージするカスタムタスク(後述)
      - run: ./gradlew ktlintWithMergedReports
      - uses: yutailang0119/action-ktlint@789ab951bb2d946262f55f509c77c1c47b9ec954 # v4.0.0
        if: ${{ always() }}
        with:
          report-path: # path #
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        if: ${{ always() }}
        with:
          name: ktlint
          path: # path #

  android_lint:
    steps:
      # ... 環境設定

      # 少年ジャンプ+ アプリケーションモジュールの android lintタスク
      - if: ${{ always() }}
        run: ./gradlew :jumpplus:lintDevelopDebug
      - uses: yutailang0119/action-android-lint@bd0b5a7d2cc453d16080b90e2a975d4af4aa9588 # v4.0.0
        if: ${{ always() }}
        with:
          report-path: jumpplus/build/reports/lint-results-developDebug.xml
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        if: ${{ always() }}
        with:
          name: android_lint_jumpplus
          path: android/jumpplus/build/reports/lint-results-developDebug.html

      # コミックガルド+(プラス) android lintタスク
      - if: ${{ always() }}
        run: ./gradlew :gardo:lintDevelopDebug
      - uses: yutailang0119/action-android-lint@bd0b5a7d2cc453d16080b90e2a975d4af4aa9588 # v4.0.0
        if: ${{ always() }}
        with:
          report-path: gardo/build/reports/lint-results-developDebug.xml
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        if: ${{ always() }}
        with:
          name: android_lint_gardo
          path: android/gardo/build/reports/lint-results-developDebug.html

ktlintのカスタムタスク実行

ktlintWithMergedReports は、マルチモジュール構成で各サブモジュールから出力される ktlint のレポートを集約し、1つのレポートにマージするカスタムタスクです。 モジュールごとに分散してしまうレポートを統一することで、CI上での確認やレポートの管理がしやすくなります。

tasks.register("ktlintWithMergedReports") {
    group = "verification"
    subprojects.forEach { subProject ->
        dependsOn("${subProject.buildTreePath}:ktlintCheck")
    }
    doLast {
        // project のルートのbuildフォルダにマージされたレポートを作成する
        val mergedReportPath = project.file("build/reports/ktlint/ktlint-merged-report.xml")
        var node: Node? = null
        subprojects.forEach { subproj ->
            val reportFiles =
                subproj.fileTree("build/reports/ktlint/") {
                    include("**/*.xml")
                }
            reportFiles.forEach { reportFile ->
                val xml = XmlParser().parse(reportFile)
                if (node == null) {
                    node = xml
                } else {
                    xml.children().forEach {
                        node!!.append(it as Node)
                    }
                }
            }
        }
        mergedReportPath.parentFile.mkdirs()
        mergedReportPath.delete()
        mergedReportPath.writeText(XmlUtil.serialize(node))
    }
}

Android Lintのエラー出力

Android Lint のエラー出力には、 action-android-lint を使用しており、
エラーは GitHub Actions の各ジョブの実行ログ上にアノテーション付きで表示され、該当のエラーを確認できるようになっています。

ktlint のカスタムルール定義

必要に応じてプロジェクトに合わせたカスタムルールも作成しています。
例えば下記は、パッケージ宣言のないKotlinファイルを検知するシンプルなルールです。

// パッケージ宣言の有無をチェックするカスタムルール。
class NoPackageDeclaration : Rule(
    ruleId = RuleId("$CUSTOM_RULE_SET_ID:no-package-declaration"),
    about = About(),
), RuleAutocorrectApproveHandler {
    override fun beforeVisitChildNodes(
        node: ASTNode,
        emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision,
    ) {
        if (node.psi.containingFile.name.endsWith(".kts")) return

        // ファイルのトップレベルでパッケージ宣言を探す
        val hasNotPackageDirective = node.findChildByType(ElementType.PACKAGE_DIRECTIVE)?.getChildren(null)?.isEmpty()
        if (hasNotPackageDirective == true) {
            emit(0, "File does not have a package declaration", false)
        }
    }
}

終わりに

ここまで、「GigaViewer for Apps」のAndroidアプリにおけるテストについてご紹介しました。
特にVRTでのPreview活用の話や、マルチテナント構成ならではのUIプラグイン対応は、GigaAppsらしいユニークなチャレンジの一つです。

テストの品質をさらに高めていく上では、今後も取り組みたい課題がいくつかあります。

  • テストピラミッドの上層(統合テスト・E2E)については、まだ機械化できていない部分が多く、品質維持・改善のために InstrumentationTest の導入を進めたい

  • VRTはまだ一部コンポーネントのみに限られているため、より多くの画面に適用していきたい

  • 最近登場した Firebase App Testing Agent のような、AI を活用したテスト実行や管理の自動化にも取り組んでみたい

GigaAppsでは、今後も品質と開発体験の両面から改善を進めていく予定です。