24、蓝牙自动化测试:使用Android Instrumentation Test模拟蓝牙行为、Mock BluetoothAdapter、UI自动化测试

自动化测试这件事,说实话,在蓝牙开发里经常被忽视。很多人觉得蓝牙这东西跟硬件打交道,测起来麻烦,干脆手点两下就完事了。但我做了这么多年蓝牙,吃过不少亏——有一次改了个扫描逻辑,自测没问题,结果CI跑了一轮,发现某个低端机型上蓝牙直接打不开。嗯,从那以后,我再也不敢轻视自动化测试了。

这一章,我就带你走一遍Android蓝牙测试的完整套路。包括怎么用Instrumentation Test模拟蓝牙行为、怎么Mock掉BluetoothAdapter、以及怎么做UI自动化测试。说白了,就是让你在没真机、没外设的情况下,也能把蓝牙代码测个七七八八。

24.1 为什么蓝牙测试这么难搞?

你想想看,蓝牙测试有几个痛点:

  • 硬件依赖:没有蓝牙芯片,你连Adapter都拿不到
  • 状态不可控:对方设备开关、距离、干扰,这些你没法在测试里控制
  • 异步回调多:扫描回调、连接回调、GATT回调,层层嵌套
  • 系统权限:BLUETOOTH_CONNECT、BLUETOOTH_SCAN这些运行时权限,模拟器上经常翻车

所以,我们的策略是:能Mock的就Mock,不能Mock的就用Instrumentation测,UI层面用Espresso跑。三层分开,互不干扰。

核心思路:把蓝牙测试拆成三层——单元测试(Mock)、集成测试(Instrumentation)、UI测试(Espresso)。每一层只关注自己的事。

24.2 Mock BluetoothAdapter——让测试脱离真机

我个人习惯,先把BluetoothAdapter给Mock掉。为什么呢?因为真机上的Adapter你没法控制它的状态。你写个测试用例想测“蓝牙关闭时扫描会抛异常”,真机上你还得手动关蓝牙,这不扯吗?

用Mockito,几行代码就搞定:

@RunWith(MockitoJUnitRunner.class)
public class BluetoothScannerTest {

    @Mock
    BluetoothAdapter mockAdapter;

    @Mock
    BluetoothLeScanner mockLeScanner;

    private BluetoothScanner scanner;

    @Before
    public void setUp() {
        // 让mockAdapter.getBluetoothLeScanner()返回mock的LeScanner
        when(mockAdapter.getBluetoothLeScanner()).thenReturn(mockLeScanner);
        when(mockAdapter.isEnabled()).thenReturn(true);

        scanner = new BluetoothScanner(mockAdapter);
    }

    @Test
    public void testStartScan_whenBluetoothOff_shouldThrowException() {
        // 模拟蓝牙关闭
        when(mockAdapter.isEnabled()).thenReturn(false);

        try {
            scanner.startScan(null);
            fail("应该抛出异常才对");
        } catch (IllegalStateException e) {
            // 符合预期
        }
    }
}

这里有个坑,我提醒你一下:BluetoothAdapter是一个单例,你没法直接new出来。所以你的代码里一定要把Adapter通过构造函数或者依赖注入传进来,别写死成BluetoothAdapter.getDefaultAdapter()。否则你Mock都Mock不了。

小技巧:我一般在项目里定义一个BluetoothAdapterProvider接口,生产环境返回真实Adapter,测试环境返回Mock。这样切换起来特别干净。

24.3 使用Android Instrumentation Test模拟蓝牙行为

Mock能解决逻辑层的问题,但有些场景你绕不开系统服务。比如你要测蓝牙开关的广播接收、或者权限弹窗的交互。这时候就得用Instrumentation Test了。

Instrumentation Test跑在真机或模拟器上,能访问真正的Android框架。但蓝牙硬件还是没法模拟,怎么办?我的做法是:用Instrumentation去模拟系统广播

举个例子,你想测“蓝牙打开时,UI上的开关状态自动更新”:

@RunWith(AndroidJUnit4.class)
public class BluetoothStatusReceiverTest {

    private Context context;
    private BluetoothStatusViewModel viewModel;

    @Before
    public void setUp() {
        context = ApplicationProvider.getApplicationContext();
        viewModel = new BluetoothStatusViewModel();
        // 注册广播接收器
        context.registerReceiver(viewModel.getReceiver(),
                new IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED));
    }

    @Test
    public void testOnBluetoothTurnedOn_shouldUpdateStatus() {
        // 模拟系统发送蓝牙打开广播
        Intent intent = new Intent(BluetoothAdapter.ACTION_STATE_CHANGED);
        intent.putExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.STATE_ON);

        // 通过Instrumentation发送广播
        InstrumentationRegistry.getInstrumentation()
                .getContext()
                .sendBroadcast(intent);

        // 验证ViewModel状态更新
        assertTrue(viewModel.isBluetoothEnabled.getValue());
    }

    @After
    public void tearDown() {
        context.unregisterReceiver(viewModel.getReceiver());
    }
}

你看,这里没有真的去开关蓝牙硬件,而是模拟了系统广播。这样既快又稳定,不会因为硬件状态不稳定导致测试失败。

注意:在Android 12以上,发送系统级广播需要BLUETOOTH_CONNECT权限。如果你的测试目标版本较高,记得在AndroidManifest里加上,或者在测试代码里用grantPermission()

24.4 UI自动化测试——用Espresso验证蓝牙交互

UI测试这块,我推荐用Espresso。它跟Instrumentation Test是同一套体系,跑在同一个进程里,速度比UI Automator快不少。

蓝牙UI测试的核心,其实就是验证“用户操作后,界面是否正确响应”。比如点击“扫描”按钮,列表里应该出现设备条目。

但这里有个问题:真实的扫描结果你没法控制。所以我的做法是——在测试里注入一个假的扫描结果

@RunWith(AndroidJUnit4.class)
public class BluetoothScanUITest {

    @Rule
    public ActivityTestRule<MainActivity> activityRule =
            new ActivityTestRule<>(MainActivity.class, true, false);

    @Before
    public void setUp() {
        // 注入Mock的BluetoothScanner
        BluetoothScanner mockScanner = mock(BluetoothScanner.class);
        when(mockScanner.startScan(any())).thenAnswer(invocation -> {
            ScanCallback callback = invocation.getArgument(0);
            // 模拟发现一个设备
            ScanResult fakeResult = createFakeScanResult("Test Device", "AA:BB:CC:DD:EE:FF");
            callback.onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, fakeResult);
            return null;
        });

        // 通过Application注入
        TestApp app = (TestApp) InstrumentationRegistry.getInstrumentation()
                .getTargetContext().getApplicationContext();
        app.setScanner(mockScanner);

        // 启动Activity
        activityRule.launchActivity(null);
    }

    @Test
    public void testScanButton_shouldShowDeviceInList() {
        // 点击扫描按钮
        onView(withId(R.id.btn_scan)).perform(click());

        // 验证列表中出现设备
        onView(withText("Test Device"))
                .check(matches(isDisplayed()));
    }

    private ScanResult createFakeScanResult(String name, String address) {
        BluetoothDevice device = mock(BluetoothDevice.class);
        when(device.getName()).thenReturn(name);
        when(device.getAddress()).thenReturn(address);
        // 构造ScanResult...
        return new ScanResult(device, null, -60, 0, 0, null);
    }
}

这里的关键是依赖注入。你的Activity不能直接new BluetoothScanner,而是从Application或者ViewModel里拿。这样测试时就能轻松替换成Mock。

24.5 整体测试架构图

下面这张图,是我自己项目里用的蓝牙测试分层架构。你可以参考一下:

蓝牙自动化测试分层架构 单元测试层(Unit Test) Mock BluetoothAdapter、BluetoothLeScanner、BluetoothGatt 验证逻辑:扫描开关、连接状态机、数据解析 工具:JUnit + Mockito + Robolectric 集成测试层(Instrumentation Test) 模拟系统广播:蓝牙开关、配对状态、连接状态 验证:广播接收器、Service交互、权限处理 工具:AndroidJUnit4 + InstrumentationRegistry UI自动化测试层(UI Test) 注入Mock数据源,模拟扫描结果、设备列表 验证:按钮点击、列表展示、状态切换、弹窗交互 工具:Espresso + ActivityTestRule + 依赖注入

这张图表达的意思很直白:从下往上,测试范围越来越大,但速度越来越慢。单元测试跑得最快,能覆盖大部分逻辑;UI测试最慢,只用来验证关键交互。我建议你按这个比例分配:单元测试占70%,集成测试占20%,UI测试占10%。

24.6 避坑指南——我踩过的几个雷

做蓝牙自动化测试这几年,我踩过不少坑。挑几个典型的跟你说说:

  • BluetoothAdapter.getDefaultAdapter()在模拟器上返回null——这个坑我刚开始做测试时就遇到了。模拟器默认没有蓝牙硬件,所以这个方法返回null。解决方案就是前面说的,用依赖注入把Adapter传进来,别直接调用静态方法。
  • ScanCallback的回调在测试线程里不执行——蓝牙扫描的回调是在Binder线程里触发的,而测试代码跑在主线程。如果你直接等回调,可能会死等。我习惯用CountDownLatch来同步,设置超时时间,避免测试卡死。
  • 权限弹窗导致UI测试失败——Android 12以上,蓝牙权限是运行时权限。Espresso跑UI测试时,如果弹窗出现,你的onView就找不到了。解决办法是在测试前用grantPermission()把权限授予,或者在@Before里处理掉弹窗。

我的习惯:每次提交代码前,先在本地跑一遍蓝牙相关的单元测试。等CI过了,再跑集成测试和UI测试。这样能最快发现问题,不用等整个流水线跑完。

24.7 小结

蓝牙自动化测试,说白了就是三件事:Mock掉硬件、模拟系统行为、注入假数据。只要把这三件事做好了,你的蓝牙代码就能在无硬件环境下跑得稳稳当当。

我个人觉得,测试这件事,越早做越好。别等到项目快上线了才补测试,那时候改一个bug可能引发三个新bug。你想想看,是不是这个道理?


公众号:蓝海资料掘金营,微信deep3321