BLE-chat 実機の疎通確認コードを追加する

BLE を使った Android 実機の自動テストコードを考えます。AI を含めたスパイラル開発では、Web API を使ったほうが実用的なような気がするのですが…ひとまず BLE のほうからやってみます。Web API 利用のほうは、先日執筆した PHP & Laravel 開発のサンプルコードを使っていずれやってみます。
テストの基準にもいろいろあるのですが、

  • 動作確認を自動化するための自動実行コード
  • 設計から生成されたコードを確認するためのテストコード
  • 完成品の品質を確認するためのテストコード

あたりから手をつけていきましょう。これも、あれこれ考えこむよりも、ひとまずテストコードを書いて動かしてみてから良い方法を考えるというアジャイル方式です。

動作確認をするための疎通確認コード

BLE を使った Android アプリの場合、いちばん面倒臭いのが疎通確認です。電波の状態が届いているのか、ライブラリがきちんと動作しているのか、送受信がうまくいっているのか、という諸々の理由から、ビルドして新しいアプリを入れ直したときに疎通確認は必須です。なんといっても、BLE が繋がっていないままにテストコードを動かしても「全然繋がらない」ために、原因を特定するのたいへんです。アプリのあれこれの動作を確認するために「確実に2台で繋がっている」あるいは「確実にメッセージが送受信されている」という状態を確保しなければいけません。ネットワーク系のテストをするときにはここが最重要ですね。

いくつか方法があると思うのですが、ひとまず Claude Code に聞いてみましょう。

BLE の疎通確認が自動化できるようにしたい。どんな方法がある?

基本は BLE を使わないで単体テストで動作する方法なのですが、これは後で追加するとして… Test Uiautomator https://developer.android.google.cn/jetpack/androidx/releases/test-uiautomator?hl=ja#2.4.0 が使えるそうなので、これを試してみます。

先のスパイラル開発に従って「数行のプロンプト」を入れてテストの動作確認コードをいれていきます。いきなりテスト設計書レベルのものは作りません。

実機2台と Uiautomator を使って、以下のテストコードを書いて

- 「Hello」を1回だけ送信するテストコード
- 「Hello」+ 通番 を1秒おきに10回送信するテストコード
- 「Hello」+ 通番 を5秒おきに5回送信するテストコード
- 受信したメッセージ adb コマンドで出力するコード

adb コマンドを使って、androidTest を呼び出すことができます。

BleTestBase.kt 内で uiautomator を使って操作

package net.moonmile.ble5_chat.claude

import android.content.Intent
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Before

/**
 * BLE チャットテストの共通基底クラス。
 *
 * 起動モードが 2 種類ある:
 *   - setUp()              … アプリを完全に再起動(BLE 初期化からやり直す)
 *   - attachToRunningApp() … 既に起動しているアプリにアタッチ(BLE 状態を維持)
 *
 * attachToRunningApp() を使う場合は、サブクラスで setUp() をオーバーライドして呼ぶ。
 */
abstract class BleTestBase {

    protected lateinit var device: UiDevice

    companion object {
        const val PACKAGE        = "net.moonmile.ble5_chat.claude"
        const val LAUNCH_TIMEOUT = 8_000L   // アプリ起動待機 (ms)
        const val BLE_INIT_WAIT  = 2_000L   // BLE 初期化待機 (ms)  ※フル起動時のみ使用
        const val ATTACH_WAIT    = 500L     // アタッチ後の UI 安定待機 (ms)
        const val SEND_SETTLE    = 500L     // 送信後の UI 安定待機 (ms)
        const val TAG            = "BleChatTest"
    }

    // ── フル起動モード ────────────────────────────────────────────
    /**
     * アプリを完全に再起動する。
     * タスクをクリアして MainActivity を新規起動し、BLE 初期化まで待機する。
     */
    @Before
    open fun setUp() {
        val instrumentation = InstrumentationRegistry.getInstrumentation()
        device = UiDevice.getInstance(instrumentation)

        // ホームに戻してから起動(前回の状態をリセット)
        device.pressHome()

        val intent = instrumentation.context.packageManager
            .getLaunchIntentForPackage(PACKAGE)
            ?.apply { addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) }
            ?: error("パッケージが見つかりません: $PACKAGE")

        instrumentation.context.startActivity(intent)

        // アプリウィンドウが表示されるまで待機
        device.wait(Until.hasObject(By.pkg(PACKAGE).depth(0)), LAUNCH_TIMEOUT)

        // BLE 初期化(スキャン開始)を待機
        Thread.sleep(BLE_INIT_WAIT)

        Log.i(TAG, "setUp: アプリ起動完了(フル起動)")
    }

    // ── アタッチモード ────────────────────────────────────────────
    /**
     * 既に起動しているアプリにアタッチする。
     *
     * FLAG_ACTIVITY_CLEAR_TASK を付けずに startActivity() を呼ぶことで、
     * 既存のタスクをそのままフォアグラウンドに戻す。
     * BLE はすでに初期化・スキャン中の状態が維持される。
     *
     * サブクラスで setUp() をオーバーライドして呼ぶ:
     *
     *   @Before
     *   override fun setUp() = attachToRunningApp()
     */
    protected fun attachToRunningApp() {
        val instrumentation = InstrumentationRegistry.getInstrumentation()
        device = UiDevice.getInstance(instrumentation)

        // フラグなし = 既存タスクをフォアグラウンドに戻すだけ(Activity は再生成されない)
        val intent = instrumentation.context.packageManager
            .getLaunchIntentForPackage(PACKAGE)
            ?: error("パッケージが見つかりません: $PACKAGE")
        // FLAG_ACTIVITY_CLEAR_TASK を意図的に付けない

        instrumentation.context.startActivity(intent)

        // ウィンドウがフォアグラウンドに来るまで待機
        device.wait(Until.hasObject(By.pkg(PACKAGE).depth(0)), LAUNCH_TIMEOUT)

        // UI 安定待機のみ(BLE 再初期化は不要)
        Thread.sleep(ATTACH_WAIT)

        Log.i(TAG, "attachToRunningApp: 既存アプリにアタッチ完了")
    }

    /**
     * メッセージを入力して送信する。
     *
     * @param text 送信するテキスト
     * @throws IllegalStateException 入力欄または送信ボタンが見つからない場合
     */
    protected fun sendMessage(text: String) {
        // テキスト入力欄を取得(Compose の OutlinedTextField は EditText として見える)
        val inputField = device.findObject(By.clazz("android.widget.EditText"))
            ?: error("入力フィールドが見つかりません。画面が正しく表示されているか確認してください。")

        inputField.clear()
        inputField.setText(text)

        // 送信ボタンをタップ(contentDescription = "送信")
        val sendButton = device.findObject(By.desc("送信"))
            ?: error("送信ボタンが見つかりません。BLE が ON になっているか確認してください。")

        sendButton.click()

        // UI が安定するまで待機
        Thread.sleep(SEND_SETTLE)
    }
}

連続送信を試す BleChatSendTest.kt

@RunWith(AndroidJUnit4::class)
class BleChatSendTest : BleTestBase() {

    /**
     * アプリを再起動せず、既に起動している BLE セッションにアタッチする。
     * BLE の初期化・スキャン状態は維持されるため、送信可能になるまでの待機が不要。
     */
    @Before
    override fun setUp() = attachToRunningApp()

    // ── テスト1: 「Hello」を1回送信 ────────────────────────────────
    @Test
    fun sendHelloOnce() {
        Log.i(TAG, "=== sendHelloOnce 開始 ===")

        sendMessage("Hello")
        Log.i(TAG, "[SEND] Hello")

        Log.i(TAG, "=== sendHelloOnce 完了 ===")
    }

    // ── テスト2: 「Hello + 通番」を1秒おきに10回送信 ───────────────
    @Test
    fun sendHelloTenTimesEverySecond() {
        val total    = 10
        val interval = 1_000L   // 1秒

        Log.i(TAG, "=== sendHelloTenTimesEverySecond 開始(${interval}ms 間隔 × $total 回)===")

        repeat(total) { index ->
            val seq = index + 1
            val msg = "Hello $seq"

            sendMessage(msg)
            Log.i(TAG, "[SEND][$seq/$total] $msg")

            if (seq < total) {
                Thread.sleep(interval)
            }
        }

        Log.i(TAG, "=== sendHelloTenTimesEverySecond 完了 ===")
    }

    // ── テスト3: 「Hello + 通番」を5秒おきに5回送信 ────────────────
    @Test
    fun sendHelloFiveTimesEveryFiveSeconds() {
        val total    = 5
        val interval = 5_000L   // 5秒

        Log.i(TAG, "=== sendHelloFiveTimesEveryFiveSeconds 開始(${interval}ms 間隔 × $total 回)===")

        repeat(total) { index ->
            val seq = index + 1
            val msg = "Hello $seq"

            sendMessage(msg)
            Log.i(TAG, "[SEND][$seq/$total] $msg")

            if (seq < total) {
                Thread.sleep(interval)
            }
        }

        Log.i(TAG, "=== sendHelloFiveTimesEveryFiveSeconds 完了 ===")
    }
}

Uiautomator の使い方がややこしいので、二の足を踏んでいたのですが、AI コーディングでここまで出来上がるのであれば、AI に任せてしまったほうが楽です。

疎通確認のためのデバッグ画面を作成する

ただし、このままだといちいち adb コマンドを叩かないといけないので、疎通確認用のデバッグ画面を追加します。

実機で疎通確認ができるように、デバッグ用の画面を「設定」→「疎通確認」という形で追加

- 「Hello」を1回だけ送信するテストコード
- 「Hello」+ 通番 を1秒おきに10回送信するテストコード
- 「Hello」+ 通番 を5秒おきに5回送信するテストコード
- 受信したメッセージをリストに表示

できあがったら、Android Studio でビルドして実機で動作確認します。

定番のボタンを押して、疎通をする簡単な画面なのですが、これがあると結構便利です。システムを作るときに、こんな感じで開発用のデバッグ画面を用意しておくと手軽にボタンやリスト表示などができて便利です。いままでは、画面を作るのにちょっと手間がかかって億劫な感じだったのですが、AI コーディングでここまで出来てくれればこれで十分です。

この画面自体は、レイアウトは特にこだわらないので AI コードディングのままで済ませておきます。

あと、2台だと疎通だけで解り辛いのですが、Android が 3台以上あると、同時に複数台に BLE アドバタイズで送信していることがわかります。

これ、実際に動作するまでわからなかったのですが、意外と BLE5 の拡張アドバタイズだと抜けがでますね。右の端末からアドバタイズ発信を1秒毎に切り替えて発信しているのですが、真ん中の端末では10件すべて受信していますが、左の端末ではとびとびで5件しか受信できていません。

これは、BLE アドバタイズを受信するときにある程度遅延するので、そこで取りこぼしが発生していると考えられます。拡張アドバタイズの場合、UDP 通信のように通信データが必ず届くとは限らないので、これはこれで正しい動作です。

ただ、なにか変なタイミングで落ちているのが気になるところ…

2026-04-30 10:08:18.601 14575-14575 BLE5Chat/B...nerManager net.moonmile.ble5_chat.claude        D  Duplicate filtered: 3d71fb74-69da-44ad-ab9d-15205c8f437f
2026-04-30 10:08:18.726 14575-14575 BLE5Chat/B...nerManager net.moonmile.ble5_chat.claude        D  Duplicate filtered: d44b0b62-7253-4a11-bb7d-052e0c82b652
2026-04-30 10:08:18.868 14575-14575 BLE5Chat/B...nerManager net.moonmile.ble5_chat.claude        D  Received: 870038ea-059e-43b7-a4e5-611339da2d92 from 11112222
2026-04-30 10:08:18.869 14575-14575 BLE5Chat/MainActivity   net.moonmile.ble5_chat.claude        D  Received: 870038ea-059e-43b7-a4e5-611339da2d92 from 11112222
2026-04-30 10:08:18.943 14575-14575 AndroidRuntime          net.moonmile.ble5_chat.claude        D  Shutting down VM
2026-04-30 10:08:18.948 14575-14575 AndroidRuntime          net.moonmile.ble5_chat.claude        E  FATAL EXCEPTION: main (Fix with AI)
                                                                                                    Process: net.moonmile.ble5_chat.claude, PID: 14575
                                                                                                    java.lang.IllegalArgumentException: Key "870038ea-059e-43b7-a4e5-611339da2d92" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.
                                                                                                    	at androidx.compose.ui.internal.InlineClassHelperKt.throwIllegalArgumentException(InlineClassHelper.kt:36)
                                                                                                    	at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose(SubcomposeLayout.kt:1366)
                                                                                                    	at androidx.compose.ui.layout.LayoutNodeSubcompositionsState$Scope.subcompose(SubcomposeLayout.kt:1231)
                                                                                                    	at androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScopeImpl.compose(LazyLayoutMeasureScope.kt:94)
                                                                                                    	at androidx.compose.foundation.lazy.layout.LazyLayoutMeasuredItemProvider.getPlaceables-3p2s80s(LazyLayoutMeasuredItem.kt:60)
                                                                                                    	at androidx.compose.foundation.lazy.LazyListMeasuredItemProvider.getAndMeasure-0kLqBqw(LazyListMeasuredItemProvider.kt:53)
                                                                                                    	at androidx.compose.foundation.lazy.LazyListMeasuredItemProvider.getAndMeasure-0kLqBqw$default(LazyListMeasuredItemProvider.kt:47)
                                                                                                    	at androidx.compose.foundation.lazy.LazyListMeasureKt.measureLazyList-LCrQqZ4(LazyListMeasure.kt:224)

おそらく、ダブりで受信「Received: 870038ea-059e-43b7-a4e5-611339da2d92 from 11112222」したときに、リストに追加する際にキーが重複しているような気がします。ここは後で。

簡単なテストコードを書いておく

BLE チャーッとツールには、明確なロジッククラスがあるわけではないので、わざわざテストコードを書く必要もないのですが、「設計から生成されたコードを確認するためのテストコード」という観点から、簡易的なテストコードを追加しておきます。

いわゆるクラス設計の通りにコーディングされている否か、をチェックします。

### 1. クラス一覧

| クラス名 | 種別 | 主な責務 |
|---|---|---|
| MainActivity | UI Host | Compose エントリポイント、`setContent`、UI 状態管理、ライフサイクル連携 |
| ChatScreen | UI Compose | 純粋な描画(State を受け取りコールバックを呼ぶ) |
| ChatRepository | Domain/Application | 送受信ユースケース統合、履歴の仲介(IF) |
| ChatRepositoryImpl | Data | `BleChatService` を使った Repository 実装 |
| BleChatService | BLE Facade | BLE 初期化、Advertiser/Scanner の開始停止管理 |
| BleAdvertiserManager | BLE Tx | Extended Advertising によるメッセージ送出 |
| BleScannerManager | BLE Rx | 広告パケット受信、重複排除 |
| MessageCodec | Protocol | 文字列とバイト列の変換(最大 100 文字制約含む) |
| DispatcherProvider | Concurrency | Coroutine Dispatcher の抽象化(テスト差し替え用) |
| DuplicateFilter | Reliability | MessageId による重複受信抑止 |
| PeerRegistry | Session | 参加端末の最終受信時刻管理(参加者把握) |
| FavoriteRepository | Data | お気に入りメッセージの追加・削除・永続化(SharedPreferences + JSON)、StateFlow で変更を通知 |
| ChatMessage | Model | チャット本文、送信者 ID、時刻、MessageId |
| AdvPacket | Model | 1広告単位データ(MessageId、Seq、Total、Payload) |
| ChatUiState | UI Model | Compose 描画用状態(メッセージ、入力値、送信可否、エラー) |
| ChatUiEvent | UI Model | UI 操作イベント(送信、入力更新、開始、停止) |
| ChatUiEffect | UI Model | 1回性イベント(Toast、権限要求誘導) |
| AppLogger | Cross-cutting | ログ収集(テスト・障害解析用) |
| ErrorHandler | Cross-cutting | BLE 例外、権限エラー、BLE OFF 検知、復旧リトライ判断 |

今回のスパイラル開発では基本的に、コードから 概要設計.md を再作成しているので、ここのクラス一覧は正しいはずです。人間の開発者ならば、設計→実装というプロセスの中で、設計通りにコーディングがされているのか?を「実装工程」としてチェックしていきます。ここは、コードレビューとか、ヒアリングとか、あるいはテスト工程でのチェックが入ります。

同じパターンを AI コーディングのプロセスでも使えないか、という試みです。

おそらく、クラス名やメソッド名をコードを検索して確認するツールがあれば十分だと思うのですが、ここでは簡易的なテストコードを書いて確認します。いわゆる、テスト駆動からの借用ですが、テストコードを先に書いて実装という流れではなく、実装コードに合わせてテストコードを書きます。当たり前ですが、必ず通るテストコードになり、このままでは意味がありません。

スパイラル開発で、次の「設計生成」が行われて「コード生成」が実行されたときに、不意にクラスやメソッドが消えていないことを確認するためのテストコードです。もちろん、設計自体が変わってしまえばテストコードの変更も必須なのですが、現時点ではコードの変更だけをチェックする簡易テストコードに絞ります。

実機の BLE を使わない単体テストコードを追加する

- net.moonmile.ble5_chat.claude.ble.MessageCodec
- net.moonmile.ble5_chat.claude.ble.DuplicateFilter
- net.moonmile.ble5_chat.claude.ble.PeerRegistry
- net.moonmile.ble5_chat.claude.util.ErrorHandler
- net.moonmile.ble5_chat.claude.util.DispatcherProvider
- net.moonmile.ble5_chat.claude.util.DefaultDispatcherProvider

データクラスの簡易テストも追加する

- net.moonmile.ble5_chat.claude.model.ChatMessage
- net.moonmile.ble5_chat.claude.model.AdvPacket
- net.moonmile.ble5_chat.claude.model.ChatUiState
- net.moonmile.ble5_chat.claude.model.ChatUiEffect
- net.moonmile.ble5_chat.claude.model.ChatUiEvent

形式的な型チェックなので、実機を使わないでテストができるクラスだけを選んでいます。

![](images/20260430_06.jpg)

コマンドラインで

./gradlew test

とすると、JUnit のテストコードが動きます。

![](images/20260430_07.jpg)

基本的に Claude Code がテストコードを書いたあとに、自らテストコードがすべて成功するまで書き直してくれるので、初回のテストコードはすべて成功するに決まっています。

中身は簡単なものばかり≒テストが通るものばかりなので、内容はあまり関係ありません。次回以降

  • 数行のプロンプトでコード生成をしたとき
  • 設計書からコード生成をしたとき

この後で、単体テストのコードを動かして「既存のコードが壊れていない事」を確認すれば ok です。
あるいは、リファクタリング目的や非互換のコードを含めようとしたときは、再び動作確認用のテストコードに AI が手を入れる、ということになります。

mock を使ったテストコードを組みこむ

  • チャットの保持 ChatRepositoryImpl
  • お気に入りの保持 FavoriteRepository

これらは、内部データに BleChatService や SharedPreferences を使っているため、単体テストコードでは動かせません。これらに対してはモック化をして、テストコードを動かせるようにします。
SharedPreferences による永続化に関しては androidTest で動かすほうがいいのですが、いったん、ここはモック化にしておきます。

モック化を使って以下のクラスのテストコードを追加

- ChatRepositoryImpl
- FavoriteRepository

途中がよくわからないのですが、テストコードを作った後に失敗してコードをやり直ししていたりしますね。

テストコードの内容はさておき、おおまかなクラスやメソッドを固定化することができました。これで、次回以降コードに何かを付け加える場合は、「既存のインターフェースを変えないように慎重にコードを変更するか」あるいは「テストコードの修正も含めて代替に設計/コード変更を行うか」を選ぶことができます。

大胆な変更としては、

  • ChatMessage データクラスを変えて BLE アドバタイズのデータ形式を変更する
  • ChatRepository を複数持てるようにして、ルームの変更などに対応する
  • チャットの配信機能を、文字ではなく色や図形などに固定して、チャット画面の UI を大幅に変更する

のような形です。

ここまで来ると、人間が開発する場合は「新しく作り直したほうが手っ取り早い」という形になりそうなのですが、果たして AI コーディングをする場合にはどうすれいいでしょうか? という話ですね。FolkBears の場合は、BLE の通信部分(GATT, Advertiser, Scanner)をライブラリ化して、それを流用する設計にしたのですが、この BLE チャットツールの場合はどうなるのか?

コード

https://github.com/moonmile/BLE-chat

  • branch: dev/test-network-cluade : 単体テストコードの追加
カテゴリー: 開発 パーマリンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

*