烟雾检测功能在Android设备上的实现通常包含两条技术路线:一是通过外接烟雾传感器获取数值,二是调用摄像头进行图像识别。无论采用哪种路线,测试的核心都不是简单点几下界面,而是要验证检测算法对异常输入的反应是否稳定。很多团队容易把烟雾检测测试和冒烟测试混为一谈,前者关注具体业务逻辑,后者只是构建后的快速验证。本文会先分析测试难点,再给出分层测试方案和代码示例。

一是测试难点与可测试性设计
Android上的烟雾检测模块往往和硬件强绑定。如果用蓝牙或串口连接烟雾传感器,测试时必须有真实设备在线,而且传感器数值会随环境温度、湿度变化,导致同一用例在不同时间跑出不同结果。基于摄像头的方案更麻烦,测试设备需要对着屏幕播放烟雾视频,无法在普通模拟器上稳定复现。所以第一步是把数据采集和检测算法隔离开。
可以定义两个接口:SmokeDataSource负责提供原始数据,可以是传感器数值,也可以是图像帧;SmokeDetector负责根据数据判断是否报警。生产环境用真实的传感器实现和摄像头实现,测试环境则注入模拟数据。这样的设计让核心算法可以脱离硬件进行单元测试,也让界面测试能够通过替换数据源来模拟报警状态。
interface SmokeDataSource {
fun readValue(): Float
fun readFrame(): Bitmap?
}
interface SmokeDetector {
fun isSmokePresent(source: SmokeDataSource): Boolean
}
class ThresholdSmokeDetector(
private val threshold: Float
) : SmokeDetector {
override fun isSmokePresent(source: SmokeDataSource): Boolean {
return source.readValue() > threshold
}
}
上面代码中,ThresholdSmokeDetector只依赖readValue()返回的浮点数,不关心数值来自真实传感器还是测试桩。这样一来,单元测试只需要传一个假的SmokeDataSource,就能覆盖阈值边界、数值跳变、持续超限等情况。对于摄像头方案,可以进一步抽象出FrameAnalyzer,把图像预处理、特征提取和分类逻辑封装成纯函数,方便用固定图片做回归测试。
二是用JUnit和Robolectric验证核心算法
单元测试先覆盖最简单的阈值检测器。用JUnit写一组针对ThresholdSmokeDetector的测试,验证当输入值刚好等于阈值、略低于阈值、略高于阈值以及为NaN时,输出是否符合预期。注意不要只测正常路径,边界值往往能暴露浮点比较和类型转换问题。
class ThresholdSmokeDetectorTest {
@Test
fun `value above threshold triggers alarm`() {
val detector = ThresholdSmokeDetector(300f)
val fakeSource = object : SmokeDataSource {
override fun readValue(): Float = 301f
override fun readFrame(): Bitmap? = null
}
assertTrue(detector.isSmokePresent(fakeSource))
}
@Test
fun `value at threshold does not trigger alarm`() {
val detector = ThresholdSmokeDetector(300f)
val fakeSource = object : SmokeDataSource {
override fun readValue(): Float = 300f
override fun readFrame(): Bitmap? = null
}
assertFalse(detector.isSmokePresent(fakeSource))
}
@Test
fun `nan input is treated as no smoke`() {
val detector = ThresholdSmokeDetector(300f)
val fakeSource = object : SmokeDataSource {
override fun readValue(): Float = Float.NaN
override fun readFrame(): Bitmap? = null
}
assertFalse(detector.isSmokePresent(fakeSource))
}
}
如果需要测试Android传感器框架的交互,Robolectric提供了ShadowSensorManager,可以在JVM上模拟传感器事件。比如应用里有一个SmokeSensorListener监听Sensor.TYPE_SMOKE(实际设备可能没有这个类型,通常使用环境传感器或自定义传感器),测试时可以先创建ShadowSensorManager,再调用sendSensorEvent推送一个模拟数值。这样不用真机也能验证监听器是否正确更新了状态。
@RunWith(RobolectricTestRunner::class)
class SmokeSensorListenerTest {
@Test
fun `listener updates alarm state on sensor event`() {
val context = ApplicationProvider.getApplicationContext<Context>()
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val shadowManager = Shadows.shadowOf(sensorManager)
val listener = SmokeSensorListener()
val sensor = shadowManager.getDefaultSensor(Sensor.TYPE_AMBIENT_TEMPERATURE)
listener.register(sensorManager, sensor)
val values = floatArrayOf(350f)
shadowManager.sendSensorEvent(sensor, values, SensorManager.SENSOR_DELAY_NORMAL)
assertTrue(listener.isAlarmTriggered())
}
}
上面代码里,Shadows.shadowOf(sensorManager)返回的ShadowSensorManager可以把模拟值直接塞给监听器,绕过了硬件层。实际项目里,环境温度传感器不太可能直接用来检测烟雾,但测试思路是一样的:先确定数据通道,再注入可控的时序数据。Robolectric测试跑在JVM上,速度比仪器测试快很多,适合放在提交前执行。
三是用Espresso验证界面联动
单元测试保证了检测算法逻辑正确,但还缺一块:当检测结果为真时,界面是否正确显示报警状态、是否触发声音或震动。这部分需要仪器测试,使用Espresso在模拟器或真机上驱动UI。烟雾检测界面通常有一个状态图标,正常时为绿色,报警时为红色,同时可能显示浓度数值。测试时不要真的去点按钮或等待传感器,而是通过依赖注入替换掉数据源。
假设MainActivity持有一个SmokeDetector和SmokeDataSource,测试可以用一个假的FakeSmokeDataSource返回固定高值,然后启动Activity并断言红色报警图标显示。为了做到这一点,建议使用ViewModel或Repository封装检测逻辑,UI只观察状态变化。这样Espresso测试可以专注验证视图状态,不用关心数据如何产生。
@RunWith(AndroidJUnit4::class)
class SmokeAlarmUiTest {
@Test
fun alarmIconShowsWhenSmokeDetected() {
val fakeSource = object : SmokeDataSource {
override fun readValue(): Float = 400f
override fun readFrame(): Bitmap? = null
}
val detector = ThresholdSmokeDetector(300f)
val scenario = ActivityScenario.launch(MainActivity::class.java)
scenario.onActivity { activity ->
activity.smokeViewModel.updateSource(fakeSource, detector)
activity.smokeViewModel.checkNow()
}
onView(withId(R.id.alarmIcon))
.check(matches(isDisplayed()))
onView(withId(R.id.alarmText))
.check(matches(withText("Smoke detected")))
}
}
如果界面会根据传感器数值实时刷新,还可以模拟连续数据流:先推送一个低于阈值的值,断言状态正常;再推送一个高于阈值的值,断言状态切换。这种测试比单纯检查一次状态更有价值,因为它覆盖了从正常到报警的状态迁移。注意Espresso在主线程上操作可能会有同步问题,建议使用InstrumentationRegistry或协程测试规则处理异步任务,避免测试偶发失败。
四是测试数据管理与CI集成
烟雾检测模块的测试数据不能只准备一组正常值和一组超标值。真实场景下,传感器可能因为接线松动瞬间跳变到极高值,也可能因为烟雾缓慢扩散而逐渐上升,还可能出现短暂尖峰后回落到正常范围。针对这些情况,测试用例至少需要覆盖四类数据:正常波动、缓慢上升、瞬时尖峰、持续超限。每类数据对应不同的期望行为,比如瞬时尖峰是否应该触发防抖逻辑,持续超限是否需要延迟确认等。
可以把这些测试数据抽成JSON或CSV文件,放在src/test/resources或src/androidTest/assets中,测试时读取并批量执行。表格可以更直观地展示测试矩阵:
| 数据场景 | 输入特征 | 期望结果 |
|---|---|---|
| 正常波动 | 200~280之间随机 | 不报警 |
| 缓慢上升 | 从250开始每帧+5,持续到320 | 越过阈值后报警 |
| 瞬时尖峰 | 350出现1帧后回落到260 | 不报警或触发防抖 |
| 持续超限 | 350持续10帧以上 | 报警并保持 |
CI集成时建议把测试分成两级。第一级是JUnit和Robolectric测试,运行在普通JVM上,每次提交都执行,耗时控制在几分钟内。第二级是Espresso仪器测试,需要模拟器或真机,可以放在合并请求阶段或夜间构建中。Gradle可以通过注解或目录区分测试类型,比如使用test任务跑JVM测试,connectedAndroidTest任务跑仪器测试。这样既保证了快速反馈,又不会让CI队列被大量模拟器占满。
最后要强调,冒烟测试(Smoke Testing)和烟雾检测测试(Smoke Detection Testing)是两个完全不同的概念。前者是软件构建后的基本可用性验证,通常只跑少量核心用例;后者是针对烟雾检测功能的业务测试。在命名测试文件和方法时尽量明确区分,避免团队成员在维护用例时产生混淆。

