HarmonyOS ArkTS 倒计时组件实战:工程实践篇 - 测试策略与质量保证

25 阅读9分钟

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

🎓 总结

本文分享了倒计时组件的完整测试策略:

  1. 单元测试:覆盖核心逻辑和边界条件
  2. 集成测试:验证组件完整流程
  3. 性能测试:确保性能指标达标
  4. 测试工具:使用合适的测试框架

关键要点:

  • 测试覆盖率 > 80%
  • 边界条件必须测试
  • 内存泄漏必须检测
  • 性能指标必须达标

在下一篇文章中,我们将总结整个系列,分享最佳实践和思考。


系列文章导航:

  • [第1篇] 基础篇:从需求分析到基础实现
  • [第2篇] 设计模式反思篇:当AI建议用策略模式时,我选择了质疑
  • [第3篇] 设计模式实践篇:标志位驱动渲染与状态机模式
  • [第4篇] 性能优化篇:从100ms刷新到流畅体验
  • [第5篇] 高级特性篇:时间区间样式切换
  • [第6篇] 工程实践篇:测试与质量保证(本文)
  • [第7篇] 总结篇:最佳实践与思考

讨论: 你在项目中是如何保证组件质量的?欢迎在评论区分享你的测试经验!