广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在Android上高效测试烟雾检测功能?

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

如何在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)是两个完全不同的概念。前者是软件构建后的基本可用性验证,通常只跑少量核心用例;后者是针对烟雾检测功能的业务测试。在命名测试文件和方法时尽量明确区分,避免团队成员在维护用例时产生混淆。

广告位 · 文章底部(后台-模块管理替换此区域)