はじめに
私は普段ネイティブアプリの開発をメイン業務としているのですが、正直に言うとテストが苦手です。
ここで言う「テスト」とは、コードで書くUnit TestやUI Testのことではなく、試験書を用いたマニュアルテストのことです。
品質を担保するために必要不可欠な工程ではありますが、スプレッドシートに試験観点をずらりと並べ、前提条件や再現手順を細かく記載し、検証用バイナリを発行して手動でポチポチ確認する……。
単純に時間がかかりますし、精神的にも削られます。
マニュアルテストを完全にゼロにすることは難しいかもしれませんが、UI Testを実装することによって、この「人間がやらなければならない試験観点」を少しでも減らせないかと考えました。
UI Testとは
Unit Test(単体テスト)が関数やクラスなどの最小単位での「内部ロジック」に対してテストを行うのに対し、UI Testとは、画面上の要素を特定して操作・検証するテストです。
例えば、「『ログイン』ボタンをクリックしたら、正しくログイン画面に遷移するか?」といった挙動をコードで担保することができます。
Webアプリであれば、PlaywrightやCypressなどを用いてE2Eテストを組んだり、ライブラリを用いてUIに対するモンキーテストを行う文化が成熟していますが、ネイティブアプリでは環境構築のハードルや実行速度の問題から、そこまで浸透していない(あるいは私が知らないだけかも?)印象があります。
ネイティブアプリならではのマニュアルテストの辛さ
ネイティブアプリは、Webアプリと比較してマニュアルテストで見るべき試験観点が多くなりがちです。
最大の理由はOSの仕様(ライフサイクルやナビゲーション)が深く関わってくるからだと思います。
1. BG(バックグラウンド) / FG(フォアグラウンド) 遷移
ネイティブアプリでは、複数のスレッドを用いて非同期に処理を行うことが一般的です。
しかし、バックグラウンドスレッドからメインスレッド(UIスレッド)を不用意に操作してしまうと、アプリはクラッシュしてしまいます。
特に、非同期処理中にアプリをBG/FGに行き来させた場合、タイミングによっては予期せぬクラッシュを引き起こすことがあります。これを手動で全パターン網羅するのは至難の業です。
2. OSの「戻るボタン」の挙動
Android特有のハードルとして、OS標準の「戻るボタン(バックキー)」があります。
例えば、画面遷移のアニメーション中にタイミングよく戻るボタンを押下すると、ナビゲーションの状態不整合が起き、「1つ前の画面に戻るつもりが、2つ前の画面に戻ってしまった」といったバグが発生することがあります。これもユーザー体験を大きく損なう要因です。
3. 状態の保持とリソース管理
Webブラウザは非常に堅牢で、あるタブでメモリリークが起きてもブラウザ全体が落ちることは稀ですし、メモリ管理の多くをブラウザ(実行環境)が肩代わりしてくれます。
しかし、ネイティブアプリはデバイスの限られたリソース(メモリ・CPU)をOS上の全アプリで共有しています。
そのため、ユーザーが他の重いアプリ(ゲームやカメラなど)を開いた際、バックグラウンドに回った自分のアプリは、OSの判断で容赦なくプロセスをキルされます(Process Death)。
その後ユーザーがアプリに戻ってきた際、「入力しかけのデータ」や「表示していた画面の状態」を正しく復元できるかは、完全にアプリの実装に委ねられています。
また、Context(Activityなどのインスタンス)のメモリリークも致命的です。長時間操作し続けると OutOfMemoryError でクラッシュするといった現象は、短時間の単純なマニュアルテストでは発見しづらく、担保が非常に難しい領域です。
そこで、Compose Test の出番
謝罪:著者のネイティブアプリ開発の知識はAndroidに偏っているため、例で出しているコードはAndroid前提のものになっています。
現在、Android開発の主流となっている Jetpack Compose には、強力なテストライブラリが用意されています。
これを使えば、上記のような「面倒な確認」の一部を自動化できます。
UI Testの実装において鍵となるのが TestTag です。
TestTag とは
Unit Testではテスト対象の関数を呼び出せば済みますが、UI Testでは「画面上のどのボタンを押すか」をテストコードに伝える必要があります。
従来のAndroid Viewでは R.id.xxx を使っていましたが、Composeでは Modifier.testTag("タグ名") を使ってUI要素に「名札」を付け、それをテスト側から探します。
実装例:ボタンを押して画面遷移(あるいは表示変化)
例えば、「ボタンを押したらテキストが変わる」という単純なUIテストは以下のように書けます。
アプリ側のコード
@Composable
fun MyScreen() {
var text by remember { mutableStateOf("未送信") }
Column {
Text(text = text, modifier = Modifier.testTag("status_text"))
Button(
onClick = { text = "送信済み" },
modifier = Modifier.testTag("submit_button") // ここでタグ付け
) {
Text("送信")
}
}
}
テストコード
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testSubmitButtonChangesText() {
// 画面を表示
composeTestRule.setContent { MyScreen() }
// 1. ボタンを押す前の状態確認
composeTestRule
.onNodeWithTag("status_text")
.assertTextEquals("未送信")
// 2. タグを指定してボタンをクリック
composeTestRule
.onNodeWithTag("submit_button")
.performClick()
// 3. ボタンを押した後の状態確認
composeTestRule
.onNodeWithTag("status_text")
.assertTextEquals("送信済み")
}
これだけで、「ボタンが押せること」「押した結果UIが変わること」をテストすることができます。
ネイティブ特有の「戻るボタン」もテストする
先ほど課題として挙げた「OSの戻るボタン」についても、UI Testで再現可能です。Espressoや ComposeTestRuleを組み合わせることで、OSレベルの操作をシミュレートできます。
@Test
fun testBackPressHandling() {
composeTestRule.setContent { MyAppNavigation() }
// 次の画面へ遷移する操作
composeTestRule.onNodeWithTag("next_screen_button").performClick()
// 遷移したことを確認
composeTestRule.onNodeWithTag("second_screen_title").assertIsDisplayed()
// ★ここでOSの戻るボタンを押下!
Espresso.pressBack()
// 元の画面に戻っていることを確認
composeTestRule.onNodeWithTag("first_screen_title").assertIsDisplayed()
}
このように書いておけば、リファクタリングでナビゲーションのロジックを壊してしまったとしても、CI(継続的インテグレーション)が「戻るボタン動いてないよ!」と教えてくれるようになります。
まとめ:マニュアルテストはなくならない、でも楽にはできる
UI Testを導入したからといって、全てのマニュアルテストが不要になるわけではありません。 レイアウトの微妙な崩れや、複雑な通信環境下での挙動、指触りの気持ちよさなどは、やはり人間の目で見る必要があります。
しかし、「ボタンを押したら画面が変わる」「戻るボタンで戻れる」といった「当たり前品質」を機械に任せることで、私たちは以下のようなメリットを得られます。
- 単純作業からの解放: スプレッドシートの項目が減る
- リグレッションの防止: コード修正時の「うっかり」を即座に検知できる
「テストが苦手」な開発者は多いと思います。そんな私たちがより開発に集中するため、UI Testを実装してみるのはいかがでしょうか。