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 整体测试架构图
下面这张图,是我自己项目里用的蓝牙测试分层架构。你可以参考一下:
这张图表达的意思很直白:从下往上,测试范围越来越大,但速度越来越慢。单元测试跑得最快,能覆盖大部分逻辑;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