您是否曾经发布过存在界面问题的应用程序?
在当今快节奏的发展背景下,这种情况变得愈发频繁——即便对那些有专门的人工QA和自动化UI测试人员的团队也是如此。
我们常低估UI(用户界面)问题的重要性。但这些问题远不只是按钮错位那么简单。许多UI缺陷会导致应用无法正常使用,例如:
- 文本色差导致可读性不足
 - 不完善的UI损害品牌可信度
 - 负面评价导致下载量下滑
 
但是等一下......我已经做过了UI测试啊。
即使您有UI测试,这并不意味着它们验证了像素的完美性。许多UI测试检查组件、屏幕或应用程序的行为,但不会检查像素级还原度。
不同类型的UI测试验证了应用程序的不同方面。使用人造数据的端到端和UI测试侧重于用户行为,但它们不检查元素之间的对齐、明暗模式的颜色正确性以及其他视觉细节。
现在有一个解决方案——那就是视觉测试。
什么是视觉测试?
视觉测试专注于识别组件和屏幕中的像素缺陷。在Android开发中,视觉测试是通过屏幕截图比较来实现的。这涉及到将UI的当前状态与基线截图(通常称为“黄金基准截图”)所包含的组件或屏幕的预期状态进行比较。
屏幕截图对比技术,允许您精准识别以下问题:
- 元素对齐与间距异常
 - 深浅模式及自定义主题中的色彩偏差
 - 各种设备(手机、平板电脑、可折叠设备)和字体大小的布局问题
 - 从右到左(RTL)和从左到右(LTR)布局中的版式渲染错误,这通常与区域设置有关
 - 本地化问题,例如当某些语言的内容超过可用空间时,文本就会溢出
 - 与显示内容相关的辅助功能问题,例如颜色对比度问题
 - 特定场景中的意外渲染问题
 
比如下图:
Android(安卓)中有多个可用于屏幕截图测试的框架,下面的表格对比了当前一些主流的测试框架:
| 框架名字 | 测试类型 | 渲染引擎 | 
|---|---|---|
| Shot | 设备测试 | 原生设备渲染 | 
| Roborazzi | 本地测试 | Robolectric原生图形 | 
| Paparazzi | 本地测试 | Layoutlib引擎 | 
| Compose预览测试 | 本地测试 | Layoutlib引擎 | 
注:"Layoutlib引擎"是Android Studio用于预览Compose组件的专用渲染引擎。
这些框架基本都遵循类似的工作流程,主要包含两个命令:
- “record”命令为您的测试生成黄金基准截图
 - “verify”命令将当前UI状态与黄金基准截图进行比较。
 
其中命令语法因框架而异。例如,在使用Shot框架时,您将使用-Precord参数来生成基线图像:
./gradlew :app:debugExecuteScreenshotTests -Precord
 
好的,这看起来很简单,但是来看个具体的例子吧。
视觉测试实战:Shot框架应用示例
为了确保截图测试的一致性,使用一致的模拟数据并使用相同的模拟器或设备(如果您使用仪器测试),这一点至关重要。使用不同的设备或模拟器会导致分辨率变化,并将导致测试失败。
让我们使用Shot框架来探究心情跟踪应用程序中统计屏幕的两个截图测试。测试将侧重于表示空和成功的UI状态(可用于图表渲染的数据)。
    companion object {
        val TEST_DATE = LocalDate(2024, Month.SEPTEMBER, 15)
    }
    @get:Rule
    val composeTestRule = createComposeRule()
    private val dateProvider = mockk<DateProvider>()
    private val moodHistoryRepository = mockk<MoodHistoryRepository>()
    @Before
    fun setUp() {
        initDI()
    }
    @Test
    fun statisticsScreen_noData() {
        val startDate = TEST_DATE.atStartOfMonth().atStartOfDayIn(defaultTimeZone)
        val endDate = TEST_DATE.atEndOfMonth().atEndOfDay()
        every { dateProvider.getCurrentDate() } returns TEST_DATE
        every { moodHistoryRepository.getAverageDayToHappiness(startDate, endDate) } returns flowOf(emptyList())
        every { moodHistoryRepository.getActivityToHappinessByDate(startDate, endDate) } returns flowOf(emptyList())
        composeTestRule.setContent {
            FeelTrackerAppTheme {
                StatisticsScreen(
                    viewModel = koinViewModel(),
                    onHome = { },
                    onBreathingPatternSelection = { },
                    onSettings = { }
                )
            }
        }
        compareScreenshot(
            rule = composeTestRule,
            name = "statisticsScreen_noData"
        )
    }
    @Test
    fun statisticsScreen_hasData() {
        val startDate = TEST_DATE.atStartOfMonth().atStartOfDayIn(defaultTimeZone)
        val endDate = TEST_DATE.atEndOfMonth().atEndOfDay()
        val firstDateOfTheMonth = TEST_DATE.atStartOfMonth()
        every { dateProvider.getCurrentDate() } returns StatisticsScreenScreenshotTest.TEST_DATE
        every { moodHistoryRepository.getAverageDayToHappiness(startDate, endDate) } returns averageDayToHappinessChartData(firstDateOfTheMonth)
        every { moodHistoryRepository.getActivityToHappinessByDate(startDate, endDate) } returns activityToHappinessData()
        composeTestRule.setContent {
            FeelTrackerAppTheme {
                StatisticsScreen(
                    viewModel = koinViewModel(),
                    onHome = { },
                    onBreathingPatternSelection = { },
                    onSettings = { }
                )
            }
        }
        compareScreenshot(
            rule = composeTestRule,
            name = "statisticsScreen_hasData"
        )
    }
    private fun initDI() {
        stopKoin()
        startKoin {
            allowOverride(true)
            androidContext(InstrumentationRegistry.getInstrumentation().targetContext)
            modules(
                ...
                module {
                    single { dateProvider }
                    single { moodHistoryRepository }
                }
            )
        }
    }
    private fun averageDayToHappinessChartData(startDate: LocalDate): Flow<List<MoodDayToHappiness>> {
        return flowOf(listOf(...))
    }
    private fun activityToHappinessData(): Flow<List<ActivityToHappiness>> {
        return flowOf(listOf(...))
    }
}
 
重要提示:
- 测试类需要实现“ScreenshotTest”接口的截图比较功能
 - 需要固定日期来确保截图测试的可重复性
 - 该测试使用数据提供商和存储库的打桩(mock)实例来模拟不同的屏幕状态。它使用“Koin”框架。
 
操作流程:
- 生成黄金基准截图的命令
 
./gradlew :app:debugExecuteScreenshotTests -Precor
 
此命令将UI的当前状态保存为未来比较的参考点。
现在,让我们通过将平均每日情绪图表的标题从“平均每日情绪”更改为“每日情绪”来模拟真实场景,并进行验证:
- 变更后验证命令
 
./gradlew :app:debugExecuteScreenshotTests
 
执行后,Shot框架会生成一份报告,在其中突出显示比较后的差异点
- 执行效果演示
 
注:此图像包含缩放效果,仅用于演示目的。
这种视觉反馈比手动检查每个组件和屏幕要高效得多。要更好地了解不同类型的UI测试之间的区别以及它们如何相互补充,请查看“并非所有UI测试都是一样的”一文。
结论
视觉测试对于交付没有视觉缺陷的应用程序至关重要。虽然UI测试侧重于行为验证,但它们无法检查应用程序的像素完整性。
通过实施视觉测试,开发团队可以在发布应用程序之前发现视觉缺陷。这些测试将发现组件错位、各种主题的颜色不正确、不同设备上的视觉问题等等。
未经打磨的UI看起来总是不专业,这可能会损害对品牌的信任度。通过将UI测试纳入开发过程,不仅检查了像素的完美性,还保护了品牌形象和声誉。
