HarmonyOS ArkTS 倒计时组件实战:工程实践篇 - 测试策略与质量保证
本文是《HarmonyOS ArkTS 企业级倒计时组件设计与实现》系列的第六篇,将深入探讨企业级组件的测试策略与质量保证。适合测试工程师、质量工程师和全栈开发者,学习如何保证组件的质量和可靠性。
📋 前言
企业级组件不仅需要功能完整,更需要质量可靠。一个没有经过充分测试的组件,在生产环境中可能会带来严重问题。本文将分享倒计时组件的完整测试策略,从单元测试到性能测试,全方位保证组件质量。
🎯 测试策略概览
测试金字塔
/\
/ \ E2E测试(少量)
/____\
/ \ 集成测试(中等)
/________\
/ \ 单元测试(大量)
/____________\
测试覆盖目标
- 单元测试覆盖率:> 80%
- 集成测试覆盖率:> 60%
- 关键路径覆盖率:100%
🧪 单元测试
1. 时间计算逻辑测试
时间计算是倒计时组件的核心逻辑,必须充分测试:
describe('refreshTimeNumber', () => {
it('应该正确计算天、时、分、秒', () => {
const view = new CountdownView()
view.keyList = 'hh:1mm:2ss'
// 测试:2天3小时4分5秒
view.refreshTimeNumber(2 * 24 * 3600 + 3 * 3600 + 4 * 60 + 5)
expect(view.D).toBe(2)
expect(view.hh).toBe(3)
expect(view.mm).toBe(4)
expect(view.ss).toBe(5)
})
it('应该正确处理hh模式下的天数折算', () => {
const view = new CountdownView()
view.keyList = 'hh:1mm:2ss'
// 测试:1天2小时,在hh模式下应该显示26小时
view.refreshTimeNumber(1 * 24 * 3600 + 2 * 3600)
expect(view.hh).toBe(26) // 1天 = 24小时,加上2小时 = 26小时
expect(view.D).toBe(1) // D仍然保留
})
it('应该正确处理ss模式下的秒数', () => {
const view = new CountdownView()
view.keyList = 'ss'
// 测试:100秒,在ss模式下应该直接显示100秒
view.refreshTimeNumber(100)
expect(view.ss).toBe(100) // 直接使用总秒数
})
it('应该正确计算毫秒', () => {
const view = new CountdownView()
// 测试:10.5秒,毫秒应该是5
view.refreshTimeNumber(10.5)
expect(view.ms).toBe(5) // (10.5 % 1) * 10 = 5
})
})
2. 边界条件测试
边界条件是最容易出错的地方:
describe('边界条件测试', () => {
it('应该正确处理0秒', () => {
const view = new CountdownView()
view.refreshTimeNumber(0)
expect(view.D).toBe(0)
expect(view.hh).toBe(0)
expect(view.mm).toBe(0)
expect(view.ss).toBe(0)
expect(view.ms).toBe(0)
})
it('应该正确处理负数(防御性编程)', () => {
const view = new CountdownView()
view.refreshTimeNumber(-10)
// 应该处理为0或抛出错误
expect(view.remainingTime).toBeGreaterThanOrEqual(0)
})
it('应该正确处理超大值', () => {
const view = new CountdownView()
const largeValue = 365 * 24 * 3600 // 1年
view.refreshTimeNumber(largeValue)
expect(view.D).toBe(365)
expect(view.hh).toBe(0)
})
it('应该正确处理浮点数精度', () => {
const view = new CountdownView()
// 测试浮点数精度问题
view.refreshTimeNumber(10.1)
expect(view.ms).toBe(1)
view.refreshTimeNumber(10.9)
expect(view.ms).toBe(9)
})
})
3. 样式切换逻辑测试
describe('样式切换逻辑', () => {
it('应该正确查找样式区间', () => {
const view = new CountdownView()
view.customStyleConfigs = [
{ beginSec: 0, endSec: 10, showStyle: 2 },
{ beginSec: 10, endSec: 60, showStyle: 0 },
]
// 测试:5秒应该在第一个区间
view.remainingTime = 5
const index = view.getNewStyleIndex()
expect(index).toBe(0)
// 测试:30秒应该在第二个区间
view.remainingTime = 30
const index2 = view.getNewStyleIndex()
expect(index2).toBe(1)
})
it('应该正确处理区间边界', () => {
const view = new CountdownView()
view.customStyleConfigs = [
{ beginSec: 0, endSec: 10, showStyle: 2 },
]
// 测试:边界值
view.remainingTime = 0
expect(view.getNewStyleIndex()).toBe(0)
view.remainingTime = 10
expect(view.getNewStyleIndex()).toBe(0) // 包含边界
view.remainingTime = 10.9
expect(view.getNewStyleIndex()).toBe(0) // 包含边界(+1)
view.remainingTime = 11
expect(view.getNewStyleIndex()).toBe(-1) // 不在区间内
})
it('应该正确处理初始状态', () => {
const view = new CountdownView()
view.totalTime = 100
view.customStyleConfigs = [
{ beginSec: 0, endSec: 10, showStyle: 2 },
]
// 测试:初始状态(remainingTime === totalTime)
view.remainingTime = 100
expect(view.getNewStyleIndex()).toBe(0) // 应该匹配第一个区间
})
})
4. 格式决定逻辑测试
describe('decideWhatToShow', () => {
it('应该正确决定默认格式', () => {
const view = new CountdownView()
view.originalEnableCustomStyle = false
view.totalTime = 3600
view.allow48ToDay = false
view.decideWhatToShow()
expect(view.keyList).toBe('hh:1mm:2ss')
})
it('应该正确处理48小时以上格式', () => {
const view = new CountdownView()
view.originalEnableCustomStyle = false
view.totalTime = 60 * 60 * 49 // 49小时
view.allow48ToDay = true
view.decideWhatToShow()
expect(view.keyList).toBe('D天hh小时')
})
it('应该正确处理自定义样式类型0', () => {
const view = new CountdownView()
view.originalEnableCustomStyle = true
view.activeCustomStyleType = 0
view.shouldHideHour = false
view.shouldShowMS = false
view.decideWhatToShow()
expect(view.keyList).toBe('hh:1mm:2ss')
expect(view.customStyleEnabled).toBe(true)
})
it('应该正确处理自定义样式类型3', () => {
const view = new CountdownView()
view.originalEnableCustomStyle = true
view.activeCustomStyleType = 3
view.activeCustomizedText = '即将开始'
view.decideWhatToShow()
expect(view.keyList).toBe('自定义文本')
expect(view.customStyleEnabled).toBe(true)
})
})
🔗 集成测试
1. 页面切换场景测试
describe('页面生命周期集成测试', () => {
it('应该在页面隐藏时暂停,显示时恢复', async () => {
const view = new CountdownView()
view.ignorePause = false
view.totalTime = 100
view.remainingTime = 100
// 启动倒计时
view.aboutToAppear()
expect(view.status).toBe(TimerStatus.RUNNING)
// 模拟页面隐藏
emitter.emit(PAGE_HIDE_EVENT)
await new Promise(resolve => setTimeout(resolve, 150))
expect(view.status).toBe(TimerStatus.PAUSED)
const pausedTime = view.remainingTime
// 模拟页面显示
emitter.emit(PAGE_SHOW_EVENT)
await new Promise(resolve => setTimeout(resolve, 150))
expect(view.status).toBe(TimerStatus.RUNNING)
// 时间应该继续递减
expect(view.remainingTime).toBeLessThan(pausedTime)
})
it('应该在组件销毁时清理资源', () => {
const view = new CountdownView()
view.aboutToAppear()
expect(view.timerId).not.toBe(0)
view.aboutToDisappear()
expect(view.timerId).toBe(0)
expect(view.status).toBe(TimerStatus.IDLE)
})
})
2. 倒计时完整流程测试
describe('倒计时完整流程测试', () => {
it('应该完整执行倒计时流程', async () => {
const view = new CountdownView()
view.totalTime = 1 // 1秒,便于测试
view.remainingTime = 1
let timeOutCalled = false
view.timeOutAction = () => {
timeOutCalled = true
}
// 启动倒计时
view.startCountdown()
expect(view.status).toBe(TimerStatus.RUNNING)
// 等待倒计时结束
await new Promise(resolve => setTimeout(resolve, 1200))
expect(view.status).toBe(TimerStatus.IDLE)
expect(view.remainingTime).toBe(0)
expect(timeOutCalled).toBe(true)
})
it('应该正确处理暂停和恢复', async () => {
const view = new CountdownView()
view.totalTime = 10
view.remainingTime = 10
view.startCountdown()
await new Promise(resolve => setTimeout(resolve, 500))
const timeBeforePause = view.remainingTime
view.pauseCountdown()
expect(view.status).toBe(TimerStatus.PAUSED)
await new Promise(resolve => setTimeout(resolve, 500))
// 暂停后时间不应该变化
expect(view.remainingTime).toBe(timeBeforePause)
// 恢复后时间应该继续递减
view.startCountdown()
await new Promise(resolve => setTimeout(resolve, 500))
expect(view.remainingTime).toBeLessThan(timeBeforePause)
})
})
3. 内存泄漏检测
describe('内存泄漏检测', () => {
it('应该在组件销毁时清理所有资源', () => {
const view = new CountdownView()
view.aboutToAppear()
// 记录初始状态
const initialTimerId = view.timerId
expect(initialTimerId).not.toBe(0)
// 销毁组件
view.aboutToDisappear()
// 验证资源清理
expect(view.timerId).toBe(0)
expect(view.status).toBe(TimerStatus.IDLE)
// 验证事件监听器已清理
// (需要mock emitter来验证)
})
it('应该防止定时器泄漏', async () => {
const views: CountdownView[] = []
// 创建多个组件
for (let i = 0; i < 10; i++) {
const view = new CountdownView()
view.aboutToAppear()
views.push(view)
}
// 销毁所有组件
views.forEach(view => view.aboutToDisappear())
// 验证所有定时器都已清理
views.forEach(view => {
expect(view.timerId).toBe(0)
})
})
})
📊 性能测试
1. 渲染性能基准测试
describe('渲染性能测试', () => {
it('单次渲染应该在5ms内完成', () => {
const view = new CountdownView()
view.remainingTime = 3600
const startTime = performance.now()
view.build()
const endTime = performance.now()
const renderTime = endTime - startTime
expect(renderTime).toBeLessThan(5) // 应该在5ms内
})
it('100次连续渲染应该在合理时间内完成', () => {
const view = new CountdownView()
view.remainingTime = 3600
const startTime = performance.now()
for (let i = 0; i < 100; i++) {
view.remainingTime -= 0.1
view.refreshTimeNumber(view.remainingTime)
view.build()
}
const endTime = performance.now()
const totalTime = endTime - startTime
expect(totalTime).toBeLessThan(500) // 100次应该在500ms内
})
})
2. 内存占用分析
describe('内存占用测试', () => {
it('单个组件内存占用应该小于2MB', () => {
const view = new CountdownView()
view.aboutToAppear()
// 获取内存占用(需要系统API支持)
// const memoryUsage = getMemoryUsage(view)
// expect(memoryUsage).toBeLessThan(2 * 1024 * 1024) // 2MB
})
it('创建100个组件不应该导致内存泄漏', async () => {
const views: CountdownView[] = []
// 创建100个组件
for (let i = 0; i < 100; i++) {
const view = new CountdownView()
view.aboutToAppear()
views.push(view)
}
// 等待一段时间
await new Promise(resolve => setTimeout(resolve, 1000))
// 销毁所有组件
views.forEach(view => view.aboutToDisappear())
// 等待垃圾回收
await new Promise(resolve => setTimeout(resolve, 1000))
// 验证内存已释放(需要系统API支持)
})
})
3. 长时间运行稳定性测试
describe('长时间运行稳定性测试', () => {
it('应该能够稳定运行1小时', async () => {
const view = new CountdownView()
view.totalTime = 3600 * 2 // 2小时
view.remainingTime = 3600 * 2
view.startCountdown()
// 运行1小时(测试中可以缩短时间)
await new Promise(resolve => setTimeout(resolve, 3600 * 1000))
// 验证状态正常
expect(view.status).toBe(TimerStatus.RUNNING)
expect(view.remainingTime).toBeGreaterThan(0)
expect(view.timerId).not.toBe(0)
})
it('应该能够处理频繁的暂停和恢复', async () => {
const view = new CountdownView()
view.totalTime = 3600
view.remainingTime = 3600
view.startCountdown()
// 频繁暂停和恢复
for (let i = 0; i < 100; i++) {
view.pauseCountdown()
await new Promise(resolve => setTimeout(resolve, 10))
view.startCountdown()
await new Promise(resolve => setTimeout(resolve, 10))
}
// 验证状态正常
expect(view.status).toBe(TimerStatus.RUNNING)
expect(view.timerId).not.toBe(0)
})
})
🛠️ 测试工具和框架
1. HarmonyOS 测试框架
HarmonyOS 提供了测试框架,可以用于组件测试:
import { describe, it, expect, beforeEach, afterEach } from '@ohos/hypium'
describe('CountdownView测试', () => {
let view: CountdownView
beforeEach(() => {
view = new CountdownView()
})
afterEach(() => {
view.aboutToDisappear()
})
it('应该正确初始化', () => {
expect(view.status).toBe(TimerStatus.IDLE)
expect(view.remainingTime).toBe(0)
})
})
2. Mock 数据设计
// Mock配置数据
const mockConfig: ElementConfig = {
x: 100,
y: 200,
fontInfo: {
size: 30,
bold: '1'
},
color: '#FF0000',
showMS: 1,
disableHour: 0,
enableCustomStyle: 1,
customStyle: [
{
beginSec: 0,
endSec: 10,
showStyle: 2,
prefixText: '仅剩',
suffixText: '秒'
}
]
}
// Mock ComponentParams
const mockViewBuilderParam: ComponentParams = {
layerId: 'test-layer',
element: {
config: mockConfig,
dataPath: {
countDown: 3600
}
},
callback: {
onClick: jest.fn(),
layerClose: jest.fn()
}
}
3. 自动化测试实践
// 自动化测试套件
describe('倒计时组件自动化测试套件', () => {
// 测试用例1:基础功能
test('基础功能测试', () => {
// ...
})
// 测试用例2:边界条件
test('边界条件测试', () => {
// ...
})
// 测试用例3:性能测试
test('性能测试', () => {
// ...
})
// 测试用例4:集成测试
test('集成测试', () => {
// ...
})
})
📋 测试覆盖率报告
覆盖率目标
- 语句覆盖率:> 80%
- 分支覆盖率:> 75%
- 函数覆盖率:> 85%
- 行覆盖率:> 80%
覆盖率报告示例
File | % Stmts | % Branch | % Funcs | % Lines
------------------------|---------|----------|---------|--------
CountdownView.ets | 85.2 | 78.5 | 90.0 | 85.2
refreshTimeNumber | 100.0 | 100.0 | 100.0 | 100.0
decideWhatToShow | 90.0 | 85.0 | 100.0 | 90.0
startCountdown | 95.0 | 90.0 | 100.0 | 95.0
pauseCountdown | 100.0 | 100.0 | 100.0 | 100.0
stopCountdown | 100.0 | 100.0 | 100.0 | 100.0
💡 测试最佳实践
1. 测试编写原则
- ✅ 测试独立:每个测试用例应该独立,不依赖其他测试
- ✅ 测试可重复:每次运行结果应该一致
- ✅ 测试快速:单元测试应该在毫秒级完成
- ✅ 测试清晰:测试名称应该清晰描述测试内容
2. 测试组织建议
tests/
├── unit/ # 单元测试
│ ├── timeCalculation.test.ets
│ ├── styleSwitch.test.ets
│ └── formatDecision.test.ets
├── integration/ # 集成测试
│ ├── lifecycle.test.ets
│ └── fullFlow.test.ets
├── performance/ # 性能测试
│ ├── render.test.ets
│ └── memory.test.ets
└── e2e/ # 端到端测试
└── userScenario.test.ets
3. 持续集成
在CI/CD流程中集成测试:
# .github/workflows/test.yml
name: 测试
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: 运行单元测试
run: npm test
- name: 生成覆盖率报告
run: npm run test:coverage
- name: 上传覆盖率
uses: codecov/codecov-action@v2
🎓 总结
本文分享了倒计时组件的完整测试策略:
- 单元测试:覆盖核心逻辑和边界条件
- 集成测试:验证组件完整流程
- 性能测试:确保性能指标达标
- 测试工具:使用合适的测试框架
关键要点:
- 测试覆盖率 > 80%
- 边界条件必须测试
- 内存泄漏必须检测
- 性能指标必须达标
在下一篇文章中,我们将总结整个系列,分享最佳实践和思考。
系列文章导航:
- [第1篇] 基础篇:从需求分析到基础实现
- [第2篇] 设计模式反思篇:当AI建议用策略模式时,我选择了质疑
- [第3篇] 设计模式实践篇:标志位驱动渲染与状态机模式
- [第4篇] 性能优化篇:从100ms刷新到流畅体验
- [第5篇] 高级特性篇:时间区间样式切换
- [第6篇] 工程实践篇:测试与质量保证(本文)
- [第7篇] 总结篇:最佳实践与思考
讨论: 你在项目中是如何保证组件质量的?欢迎在评论区分享你的测试经验!