HarmonyOS开发:TDD实践与测试驱动开发

举报
Jack20 发表于 2026/06/24 15:35:24 2026/06/24
【摘要】 HarmonyOS开发:TDD实践与测试驱动开发📌 核心要点:TDD不是"先写测试再写代码"这么简单——它是"红-绿-重构"的循环纪律:先写一个失败的测试(红),写最少的代码让它通过(绿),然后优化代码结构(重构)。在鸿蒙项目中落地TDD,需要选对切入点、控制好节奏、扛住"先写测试太慢"的心理压力。 一、背景与动机你有没有过这种经历?代码写完了,功能也测了,看起来没问题。过了两天改了个小...

HarmonyOS开发:TDD实践与测试驱动开发

📌 核心要点:TDD不是"先写测试再写代码"这么简单——它是"红-绿-重构"的循环纪律:先写一个失败的测试(红),写最少的代码让它通过(绿),然后优化代码结构(重构)。在鸿蒙项目中落地TDD,需要选对切入点、控制好节奏、扛住"先写测试太慢"的心理压力。


一、背景与动机

你有没有过这种经历?代码写完了,功能也测了,看起来没问题。过了两天改了个小需求,结果之前的功能悄悄挂了——你根本不知道,因为没人跑测试。或者更常见的情况:代码写完才发现边界条件没处理,返回去加if-else,改着改着又引入了新Bug。

这些问题的根源都是一样的:你先写了代码,测试只是事后补的"验证"。补测试的时候,你的思维已经被实现细节框住了——你会下意识地按代码的逻辑写测试,而不是按需求写测试。结果就是:测试覆盖了你的代码,但没有覆盖你的需求。

TDD(Test-Driven Development)把顺序反过来:先写测试,再写代码。听起来反直觉?确实。但它解决了一个根本问题:测试驱动设计,而不是实现驱动测试。当你先写测试时,你被迫从"使用者"的角度思考API该怎么设计——参数是什么、返回什么、异常怎么处理。这种思考方式产出的代码,天然就是好用的、好测的。


二、核心原理

2.1 TDD红-绿-重构循环

flowchart TB
    A[🔴 红:写失败测试] --> B[🟢 绿:写最少代码通过]
    B --> C[🔵 重构:优化代码结构]
    C --> D{还有新需求?}
    D -->|| A
    D -->|| E[✅ 完成]

    A -.-> A1[只写测试<br/>不写实现]
    B -.-> B1[只写让测试通过<br/>的最少代码]
    C -.-> C1[测试全绿时<br/>才重构]

    classDef redStyle fill:#F44336,stroke:#D32F2F,color:#fff,font-weight:bold
    classDef greenStyle fill:#4CAF50,stroke:#388E3C,color:#fff,font-weight:bold
    classDef blueStyle fill:#2196F3,stroke:#1976D2,color:#fff,font-weight:bold
    classDef decisionStyle fill:#FF9800,stroke:#F57C00,color:#fff
    classDef doneStyle fill:#9C27B0,stroke:#7B1FA2,color:#fff,font-weight:bold
    classDef noteStyle fill:#607D8B,stroke:#455A64,color:#fff

    class A,A1 redStyle
    class B,B1 greenStyle
    class C,C1 blueStyle
    class D decisionStyle
    class E doneStyle

2.2 TDD三定律

Kent Beck提出的TDD三定律,是执行TDD的硬性规则:

定律 内容 为什么
第一定律 除非为了使一个失败的单元测试通过,否则不允许写任何产品代码 防止写不需要的代码
第二定律 只允许写刚好能够体现一个失败的单元测试 防止测试太超前
第三定律 只允许写刚好能够使一个失败测试通过的代码 防止过度设计

这三条定律的核心精神:小步前进,每次只做一件事

2.3 TDD vs 传统开发对比

维度 传统开发 TDD
开发顺序 先写代码,后补测试 先写测试,再写代码
设计驱动力 实现驱动 接口驱动
调试方式 手动调试 + 断点 测试即文档
重构信心 不敢重构(怕改坏) 敢重构(测试兜底)
边界处理 经常遗漏 测试先行,自然覆盖
代码质量 事后检查 过程保证

三、代码实战

3.1 基础用法:用TDD开发一个FizzBuzz

需求:输入一个整数,返回对应字符串。3的倍数返回"Fizz",5的倍数返回"Buzz",同时是3和5的倍数返回"FizzBuzz",其他情况返回数字本身。

第一步:红——写失败测试

// FizzBuzzTest.ets - 先写测试
import { describe, it } from '@ohos/hypium'
import { FizzBuzz } from '../../../main/ets/utils/FizzBuzz'

export default function fizzBuzzTest() {
  describe('FizzBuzz', () => {
    // 第一个测试:普通数字返回自身
    it('convert_1_返回1', 0, () => {
      assertEqual(FizzBuzz.convert(1), '1')
    })
  })
}

此时FizzBuzz类还不存在,测试必然失败(编译错误)。这就是"红"。

第二步:绿——写最少代码让测试通过

// FizzBuzz.ets - 最简实现
export class FizzBuzz {
  static convert(n: number): string {
    return '1'
  }
}

测试通过了!但代码明显是硬编码的。没关系,TDD允许这样做——先让测试通过,再逐步完善。

第三步:红——再加一个失败测试

it('convert_2_返回2', 0, () => {
  assertEqual(FizzBuzz.convert(2), '2')
})

第四步:绿——修改代码让两个测试都通过

export class FizzBuzz {
  static convert(n: number): string {
    return String(n)
  }
}

继续红-绿循环:

// 红加测试
it('convert_3_返回Fizz', 0, () => {
  assertEqual(FizzBuzz.convert(3), 'Fizz')
})

// 绿修改代码
export class FizzBuzz {
  static convert(n: number): string {
    if (n % 3 === 0) return 'Fizz'
    return String(n)
  }
}

// 红加测试
it('convert_5_返回Buzz', 0, () => {
  assertEqual(FizzBuzz.convert(5), 'Buzz')
})

// 绿修改代码
export class FizzBuzz {
  static convert(n: number): string {
    if (n % 3 === 0) return 'Fizz'
    if (n % 5 === 0) return 'Buzz'
    return String(n)
  }
}

// 红加测试
it('convert_15_返回FizzBuzz', 0, () => {
  assertEqual(FizzBuzz.convert(15), 'FizzBuzz')
})

// 绿修改代码
export class FizzBuzz {
  static convert(n: number): string {
    if (n % 15 === 0) return 'FizzBuzz'
    if (n % 3 === 0) return 'FizzBuzz'.slice(0, 4)  // 临时方案
    if (n % 5 === 0) return 'Buzz'
    return String(n)
  }
}

等等,上面的代码有问题。重构一下:

第五步:重构——在测试全绿时优化代码

// FizzBuzz.ets - 重构后的最终版本
export class FizzBuzz {
  static convert(n: number): string {
    const parts: string[] = []
    if (n % 3 === 0) parts.push('Fizz')
    if (n % 5 === 0) parts.push('Buzz')
    return parts.length > 0 ? parts.join('') : String(n)
  }
}

重构后所有测试仍然通过,代码更清晰、更易扩展。

完整的测试文件:

// FizzBuzzTest.ets - 完整TDD测试
import { describe, it } from '@ohos/hypium'
import { FizzBuzz } from '../../../main/ets/utils/FizzBuzz'

export default function fizzBuzzTest() {
  describe('FizzBuzz', () => {
    it('convert_1_返回1', 0, () => {
      assertEqual(FizzBuzz.convert(1), '1')
    })

    it('convert_2_返回2', 0, () => {
      assertEqual(FizzBuzz.convert(2), '2')
    })

    it('convert_3_返回Fizz', 0, () => {
      assertEqual(FizzBuzz.convert(3), 'Fizz')
    })

    it('convert_4_返回4', 0, () => {
      assertEqual(FizzBuzz.convert(4), '4')
    })

    it('convert_5_返回Buzz', 0, () => {
      assertEqual(FizzBuzz.convert(5), 'Buzz')
    })

    it('convert_6_返回Fizz', 0, () => {
      assertEqual(FizzBuzz.convert(6), 'Fizz')
    })

    it('convert_10_返回Buzz', 0, () => {
      assertEqual(FizzBuzz.convert(10), 'Buzz')
    })

    it('convert_15_返回FizzBuzz', 0, () => {
      assertEqual(FizzBuzz.convert(15), 'FizzBuzz')
    })

    it('convert_30_返回FizzBuzz', 0, () => {
      assertEqual(FizzBuzz.convert(30), 'FizzBuzz')
    })
  })
}

3.2 进阶用法:用TDD开发一个表单验证器

需求:验证用户注册表单——用户名3-20位字母数字、邮箱格式合法、密码8-20位含大小写和数字、确认密码一致。

// 第一步:红——写失败测试
// FormValidatorTest.ets
import { describe, it } from '@ohos/hypium'
import { FormValidator } from '../../../main/ets/utils/FormValidator'

export default function formValidatorTest() {
  describe('FormValidator表单验证', () => {
    describe('validateUsername用户名验证', () => {
      it('validateUsername_合法用户名_返回valid', 0, () => {
        const result = FormValidator.validateUsername('zhangsan123')
        assertEqual(result.valid, true)
        assertEqual(result.errors.length, 0)
      })

      it('validateUsername_过短_返回错误', 0, () => {
        const result = FormValidator.validateUsername('ab')
        assertEqual(result.valid, false)
      })

      it('validateUsername_过长_返回错误', 0, () => {
        const result = FormValidator.validateUsername('a'.repeat(21))
        assertEqual(result.valid, false)
      })

      it('validateUsername_包含特殊字符_返回错误', 0, () => {
        const result = FormValidator.validateUsername('zhang@san')
        assertEqual(result.valid, false)
      })

      it('validateUsername_空值_返回错误', 0, () => {
        const result = FormValidator.validateUsername('')
        assertEqual(result.valid, false)
      })
    })

    describe('validateEmail邮箱验证', () => {
      it('validateEmail_合法邮箱_返回valid', 0, () => {
        const result = FormValidator.validateEmail('test@example.com')
        assertEqual(result.valid, true)
      })

      it('validateEmail_缺少@_返回错误', 0, () => {
        const result = FormValidator.validateEmail('testexample.com')
        assertEqual(result.valid, false)
      })

      it('validateEmail_缺少域名_返回错误', 0, () => {
        const result = FormValidator.validateEmail('test@')
        assertEqual(result.valid, false)
      })

      it('validateEmail_空值_返回错误', 0, () => {
        const result = FormValidator.validateEmail('')
        assertEqual(result.valid, false)
      })
    })

    describe('validatePassword密码验证', () => {
      it('validatePassword_合法密码_返回valid', 0, () => {
        const result = FormValidator.validatePassword('Test1234')
        assertEqual(result.valid, true)
      })

      it('validatePassword_过短_返回错误', 0, () => {
        const result = FormValidator.validatePassword('Te1')
        assertEqual(result.valid, false)
      })

      it('validatePassword_缺少大写_返回错误', 0, () => {
        const result = FormValidator.validatePassword('test1234')
        assertEqual(result.valid, false)
      })

      it('validatePassword_缺少小写_返回错误', 0, () => {
        const result = FormValidator.validatePassword('TEST1234')
        assertEqual(result.valid, false)
      })

      it('validatePassword_缺少数字_返回错误', 0, () => {
        const result = FormValidator.validatePassword('Testtest')
        assertEqual(result.valid, false)
      })
    })

    describe('validateConfirmPassword确认密码', () => {
      it('validateConfirmPassword_一致_返回valid', 0, () => {
        const result = FormValidator.validateConfirmPassword('Test1234', 'Test1234')
        assertEqual(result.valid, true)
      })

      it('validateConfirmPassword_不一致_返回错误', 0, () => {
        const result = FormValidator.validateConfirmPassword('Test1234', 'Test5678')
        assertEqual(result.valid, false)
      })
    })

    describe('validateRegistration完整注册验证', () => {
      it('validateRegistration_全部合法_返回valid', 0, () => {
        const result = FormValidator.validateRegistration({
          username: 'zhangsan',
          email: 'zhang@test.com',
          password: 'Test1234',
          confirmPassword: 'Test1234'
        })
        assertEqual(result.valid, true)
        assertEqual(result.errors.length, 0)
      })

      it('validateRegistration_多个字段非法_返回多个错误', 0, () => {
        const result = FormValidator.validateRegistration({
          username: 'a',
          email: 'invalid',
          password: '123',
          confirmPassword: '456'
        })
        assertEqual(result.valid, false)
        assertTrue(result.errors.length >= 3)
      })
    })
  })
}

第二步:绿——逐步实现让所有测试通过

// FormValidator.ets - TDD驱动实现
export interface ValidationResult {
  valid: boolean
  errors: string[]
}

export interface RegistrationData {
  username: string
  email: string
  password: string
  confirmPassword: string
}

export class FormValidator {
  static validateUsername(username: string): ValidationResult {
    const errors: string[] = []

    if (!username || username.trim().length === 0) {
      errors.push('用户名不能为空')
      return { valid: false, errors }
    }

    if (username.length < 3) {
      errors.push('用户名不能少于3个字符')
    }

    if (username.length > 20) {
      errors.push('用户名不能超过20个字符')
    }

    if (!/^[a-zA-Z0-9]+$/.test(username)) {
      errors.push('用户名只能包含字母和数字')
    }

    return { valid: errors.length === 0, errors }
  }

  static validateEmail(email: string): ValidationResult {
    const errors: string[] = []

    if (!email || email.trim().length === 0) {
      errors.push('邮箱不能为空')
      return { valid: false, errors }
    }

    const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
    if (!emailPattern.test(email)) {
      errors.push('邮箱格式不正确')
    }

    return { valid: errors.length === 0, errors }
  }

  static validatePassword(password: string): ValidationResult {
    const errors: string[] = []

    if (!password || password.length === 0) {
      errors.push('密码不能为空')
      return { valid: false, errors }
    }

    if (password.length < 8) {
      errors.push('密码不能少于8个字符')
    }

    if (password.length > 20) {
      errors.push('密码不能超过20个字符')
    }

    if (!/[A-Z]/.test(password)) {
      errors.push('密码必须包含大写字母')
    }

    if (!/[a-z]/.test(password)) {
      errors.push('密码必须包含小写字母')
    }

    if (!/[0-9]/.test(password)) {
      errors.push('密码必须包含数字')
    }

    return { valid: errors.length === 0, errors }
  }

  static validateConfirmPassword(password: string, confirmPassword: string): ValidationResult {
    const errors: string[] = []

    if (password !== confirmPassword) {
      errors.push('两次输入的密码不一致')
    }

    return { valid: errors.length === 0, errors }
  }

  static validateRegistration(data: RegistrationData): ValidationResult {
    const errors: string[] = []

    const usernameResult = FormValidator.validateUsername(data.username)
    const emailResult = FormValidator.validateEmail(data.email)
    const passwordResult = FormValidator.validatePassword(data.password)
    const confirmResult = FormValidator.validateConfirmPassword(data.password, data.confirmPassword)

    errors.push(...usernameResult.errors)
    errors.push(...emailResult.errors)
    errors.push(...passwordResult.errors)
    errors.push(...confirmResult.errors)

    return { valid: errors.length === 0, errors }
  }
}

3.3 完整示例:TDD开发鸿蒙ViewModel

用TDD开发一个完整的ViewModel,展示从需求到测试到代码的完整流程。

需求:待办事项ViewModel

  • 加载待办列表
  • 添加新待办
  • 切换完成状态
  • 删除待办
  • 按状态过滤
// TodoRepository.ets - 数据仓库接口(依赖)
export interface TodoItem {
  id: string
  title: string
  completed: boolean
  createdAt: number
}

export interface TodoFilter {
  type: 'all' | 'active' | 'completed'
}

export class TodoRepository {
  private todos: Map<string, TodoItem> = new Map()
  private idCounter: number = 0

  async getAll(): Promise<TodoItem[]> {
    return Array.from(this.todos.values()).sort((a, b) => b.createdAt - a.createdAt)
  }

  async add(title: string): Promise<TodoItem> {
    const id = `todo_${++this.idCounter}`
    const todo: TodoItem = {
      id,
      title,
      completed: false,
      createdAt: Date.now()
    }
    this.todos.set(id, todo)
    return todo
  }

  async toggle(id: string): Promise<TodoItem | null> {
    const todo = this.todos.get(id)
    if (!todo) return null
    todo.completed = !todo.completed
    return todo
  }

  async delete(id: string): Promise<boolean> {
    return this.todos.delete(id)
  }

  async getByFilter(filter: TodoFilter): Promise<TodoItem[]> {
    const all = await this.getAll()
    switch (filter.type) {
      case 'active': return all.filter(t => !t.completed)
      case 'completed': return all.filter(t => t.completed)
      default: return all
    }
  }

  clear(): void {
    this.todos.clear()
    this.idCounter = 0
  }
}

TDD第一步:写测试(红)

// TodoViewModelTest.ets - 先写测试,ViewModel还不存在
import { describe, it, beforeEach } from '@ohos/hypium'
import { TodoViewModel } from '../../../main/ets/viewmodel/TodoViewModel'
import { TodoRepository } from '../../../main/ets/repository/TodoRepository'

// 创建测试用的Repository Stub
class TodoRepositoryStub extends TodoRepository {
  private mockTodos: TodoItem[] = []
  private addCalled: boolean = false
  private lastAddedTitle: string = ''

  setMockTodos(todos: TodoItem[]): void {
    this.mockTodos = todos
  }

  wasAddCalled(): boolean {
    return this.addCalled
  }

  getLastAddedTitle(): string {
    return this.lastAddedTitle
  }

  async getAll(): Promise<TodoItem[]> {
    return [...this.mockTodos]
  }

  async add(title: string): Promise<TodoItem> {
    this.addCalled = true
    this.lastAddedTitle = title
    const todo: TodoItem = {
      id: `stub_${Date.now()}`,
      title,
      completed: false,
      createdAt: Date.now()
    }
    this.mockTodos.unshift(todo)
    return todo
  }

  async toggle(id: string): Promise<TodoItem | null> {
    const todo = this.mockTodos.find(t => t.id === id)
    if (!todo) return null
    todo.completed = !todo.completed
    return todo
  }

  async delete(id: string): Promise<boolean> {
    const index = this.mockTodos.findIndex(t => t.id === id)
    if (index === -1) return false
    this.mockTodos.splice(index, 1)
    return true
  }

  async getByFilter(filter: { type: string }): Promise<TodoItem[]> {
    switch (filter.type) {
      case 'active': return this.mockTodos.filter(t => !t.completed)
      case 'completed': return this.mockTodos.filter(t => t.completed)
      default: return [...this.mockTodos]
    }
  }
}

export default function todoViewModelTest() {
  describe('TodoViewModel待办事项', () => {
    let repository: TodoRepositoryStub
    let viewModel: TodoViewModel

    beforeEach(() => {
      repository = new TodoRepositoryStub()
      viewModel = new TodoViewModel(repository)
    })

    describe('loadTodos加载待办', () => {
      it('loadTodos_空列表_显示空状态', 0, async () => {
        repository.setMockTodos([])
        await viewModel.loadTodos()
        assertEqual(viewModel.getTodos().length, 0)
        assertTrue(viewModel.isEmpty())
      })

      it('loadTodos_有待办_显示列表', 0, async () => {
        repository.setMockTodos([
          { id: '1', title: '买菜', completed: false, createdAt: Date.now() },
          { id: '2', title: '写代码', completed: true, createdAt: Date.now() }
        ])
        await viewModel.loadTodos()
        assertEqual(viewModel.getTodos().length, 2)
        assertFalse(viewModel.isEmpty())
      })

      it('loadTodos_加载中状态_正确', 0, async () => {
        repository.setMockTodos([])
        const loadPromise = viewModel.loadTodos()
        assertTrue(viewModel.isLoading())
        await loadPromise
        assertFalse(viewModel.isLoading())
      })
    })

    describe('addTodo添加待办', () => {
      it('addTodo_空标题_不添加', 0, async () => {
        await viewModel.addTodo('')
        assertFalse(repository.wasAddCalled())
      })

      it('addTodo_纯空格_不添加', 0, async () => {
        await viewModel.addTodo('   ')
        assertFalse(repository.wasAddCalled())
      })

      it('addTodo_合法标题_调用Repository', 0, async () => {
        await viewModel.addTodo('学习TDD')
        assertTrue(repository.wasAddCalled())
        assertEqual(repository.getLastAddedTitle(), '学习TDD')
      })

      it('addTodo_添加后刷新列表', 0, async () => {
        repository.setMockTodos([
          { id: '1', title: '买菜', completed: false, createdAt: Date.now() }
        ])
        await viewModel.loadTodos()
        assertEqual(viewModel.getTodos().length, 1)

        await viewModel.addTodo('学习TDD')
        // addTodo内部会重新加载列表
        assertTrue(viewModel.getTodos().length >= 1)
      })
    })

    describe('toggleTodo切换状态', () => {
      it('toggleTodo_未完成变完成', 0, async () => {
        repository.setMockTodos([
          { id: '1', title: '买菜', completed: false, createdAt: Date.now() }
        ])
        await viewModel.loadTodos()
        await viewModel.toggleTodo('1')

        const todo = viewModel.getTodos().find(t => t.id === '1')
        assertEqual(todo!.completed, true)
      })
    })

    describe('deleteTodo删除待办', () => {
      it('deleteTodo_删除后列表更新', 0, async () => {
        repository.setMockTodos([
          { id: '1', title: '买菜', completed: false, createdAt: Date.now() },
          { id: '2', title: '写代码', completed: true, createdAt: Date.now() }
        ])
        await viewModel.loadTodos()
        assertEqual(viewModel.getTodos().length, 2)

        await viewModel.deleteTodo('1')
        assertEqual(viewModel.getTodos().length, 1)
      })
    })

    describe('setFilter过滤', () => {
      beforeEach(async () => {
        repository.setMockTodos([
          { id: '1', title: '买菜', completed: false, createdAt: Date.now() },
          { id: '2', title: '写代码', completed: true, createdAt: Date.now() },
          { id: '3', title: '跑步', completed: false, createdAt: Date.now() }
        ])
        await viewModel.loadTodos()
      })

      it('setFilter_all_显示全部', 0, async () => {
        await viewModel.setFilter({ type: 'all' })
        assertEqual(viewModel.getTodos().length, 3)
      })

      it('setFilter_active_只显示未完成', 0, async () => {
        await viewModel.setFilter({ type: 'active' })
        assertEqual(viewModel.getTodos().length, 2)
        assertTrue(viewModel.getTodos().every(t => !t.completed))
      })

      it('setFilter_completed_只显示已完成', 0, async () => {
        await viewModel.setFilter({ type: 'completed' })
        assertEqual(viewModel.getTodos().length, 1)
        assertTrue(viewModel.getTodos().every(t => t.completed))
      })
    })

    describe('getActiveCount未完成数量', () => {
      it('getActiveCount_混合状态_正确计数', 0, async () => {
        repository.setMockTodos([
          { id: '1', title: '买菜', completed: false, createdAt: Date.now() },
          { id: '2', title: '写代码', completed: true, createdAt: Date.now() },
          { id: '3', title: '跑步', completed: false, createdAt: Date.now() }
        ])
        await viewModel.loadTodos()
        assertEqual(viewModel.getActiveCount(), 2)
      })
    })
  })
}

TDD第二步:实现ViewModel(绿)

// TodoViewModel.ets - 测试驱动实现
import { TodoRepository, TodoItem, TodoFilter } from '../repository/TodoRepository'

export class TodoViewModel {
  private repository: TodoRepository
  private todos: TodoItem[] = []
  private currentFilter: TodoFilter = { type: 'all' }
  private loading: boolean = false
  private errorMessage: string = ''

  constructor(repository: TodoRepository) {
    this.repository = repository
  }

  async loadTodos(): Promise<void> {
    this.loading = true
    this.errorMessage = ''

    try {
      this.todos = await this.repository.getByFilter(this.currentFilter)
    } catch (e) {
      this.errorMessage = (e as Error).message
    } finally {
      this.loading = false
    }
  }

  async addTodo(title: string): Promise<void> {
    const trimmedTitle = title.trim()
    if (trimmedTitle.length === 0) {
      return  // 空标题不添加
    }

    try {
      await this.repository.add(trimmedTitle)
      await this.loadTodos()  // 重新加载列表
    } catch (e) {
      this.errorMessage = (e as Error).message
    }
  }

  async toggleTodo(id: string): Promise<void> {
    try {
      await this.repository.toggle(id)
      await this.loadTodos()
    } catch (e) {
      this.errorMessage = (e as Error).message
    }
  }

  async deleteTodo(id: string): Promise<void> {
    try {
      await this.repository.delete(id)
      await this.loadTodos()
    } catch (e) {
      this.errorMessage = (e as Error).message
    }
  }

  async setFilter(filter: TodoFilter): Promise<void> {
    this.currentFilter = filter
    await this.loadTodos()
  }

  getTodos(): TodoItem[] {
    return [...this.todos]
  }

  isLoading(): boolean {
    return this.loading
  }

  isEmpty(): boolean {
    return this.todos.length === 0
  }

  getErrorMessage(): string {
    return this.errorMessage
  }

  getActiveCount(): number {
    // 需要从全量数据中计算,不受filter影响
    return this.todos.filter(t => !t.completed).length
  }
}

TDD第三步:重构

测试全绿后,审视代码,发现getActiveCount在过滤模式下返回的是过滤后的未完成数,不是全量的。需要修复:

// 重构:getActiveCount应该基于全量数据
export class TodoViewModel {
  private repository: TodoRepository
  private allTodos: TodoItem[] = []       // 新增:保存全量数据
  private filteredTodos: TodoItem[] = []  // 过滤后的数据
  private currentFilter: TodoFilter = { type: 'all' }
  private loading: boolean = false
  private errorMessage: string = ''

  constructor(repository: TodoRepository) {
    this.repository = repository
  }

  async loadTodos(): Promise<void> {
    this.loading = true
    this.errorMessage = ''

    try {
      this.allTodos = await this.repository.getAll()
      this.applyFilter()
    } catch (e) {
      this.errorMessage = (e as Error).message
    } finally {
      this.loading = false
    }
  }

  private applyFilter(): void {
    switch (this.currentFilter.type) {
      case 'active':
        this.filteredTodos = this.allTodos.filter(t => !t.completed)
        break
      case 'completed':
        this.filteredTodos = this.allTodos.filter(t => t.completed)
        break
      default:
        this.filteredTodos = [...this.allTodos]
    }
  }

  async addTodo(title: string): Promise<void> {
    const trimmedTitle = title.trim()
    if (trimmedTitle.length === 0) return

    try {
      await this.repository.add(trimmedTitle)
      await this.loadTodos()
    } catch (e) {
      this.errorMessage = (e as Error).message
    }
  }

  async toggleTodo(id: string): Promise<void> {
    try {
      await this.repository.toggle(id)
      await this.loadTodos()
    } catch (e) {
      this.errorMessage = (e as Error).message
    }
  }

  async deleteTodo(id: string): Promise<void> {
    try {
      await this.repository.delete(id)
      await this.loadTodos()
    } catch (e) {
      this.errorMessage = (e as Error).message
    }
  }

  async setFilter(filter: TodoFilter): Promise<void> {
    this.currentFilter = filter
    this.applyFilter()
  }

  getTodos(): TodoItem[] {
    return [...this.filteredTodos]
  }

  isLoading(): boolean {
    return this.loading
  }

  isEmpty(): boolean {
    return this.filteredTodos.length === 0
  }

  getErrorMessage(): string {
    return this.errorMessage
  }

  getActiveCount(): number {
    return this.allTodos.filter(t => !t.completed).length
  }
}

重构后跑一遍测试——全绿,放心提交。


四、踩坑与注意事项

坑点1:“先写测试太慢了”

这是TDD最大的心理障碍。确实,一开始写测试会慢——因为你还不熟练。但熟练之后,TDD反而更快:测试即文档,不用写注释;测试即调试,不用手动验证;测试即安全网,不怕改坏。前期的"慢"换来的是后期的"快"。

坑点2:测试写得太细

测试每个私有方法?测试每一行代码?这不是TDD,这是过度测试。TDD测的是行为,不是实现。公开接口的行为测清楚了,私有方法自然被覆盖。测私有方法,重构时测试就废了。

坑点3:跳过红阶段

“我直接写实现,然后补个测试不就完了?”——这不叫TDD,这叫"事后补测试"。事后补测试,你的思维已经被实现框住了,测试会下意识地按代码逻辑写,而不是按需求写。红阶段的价值在于:迫使你在写代码之前想清楚"我要什么"

坑点4:重构时不敢改测试

重构是改代码不改行为。如果重构时你需要改测试,说明你的测试耦合了实现细节。好的测试只验证行为,不验证实现。如果测试和实现耦合太紧,重构时先解耦测试——把依赖实现的断言改成依赖行为的断言。

坑点5:一步跨太大

一次写5个测试再写实现?这不是TDD。TDD的核心是小步前进:一次只加一个测试,一次只写让这个测试通过的最少代码。步子太大,出问题时你不知道是哪个测试或哪段代码导致的。

坑点6:TDD不适合所有场景

UI布局、CSS样式、一次性脚本——这些场景用TDD是杀鸡用牛刀。TDD最适合的是有明确输入输出的逻辑代码:计算、验证、转换、状态管理。先把核心逻辑用TDD写好,UI层手动验证即可。

坑点7:团队没有TDD文化

一个人写TDD,其他人不写,代码库里TDD和非TDD混在一起,维护成本反而更高。TDD需要团队共识——要么全团队一起实践,要么先在小范围试点,证明价值后再推广。


五、HarmonyOS 6适配说明

API差异表

功能/接口 HarmonyOS 5 HarmonyOS 6 变更说明
TDD工具 DevEco TDD助手 内置TDD工作流
测试模板 手动创建 TDD模板 新建文件时选TDD模板
即时测试 手动运行 保存自动运行 代码保存时自动跑测试
重构辅助 手动 安全重构 重构时自动调整测试
测试导航 手动查找 测试/实现跳转 Ctrl+T在测试和实现间切换

行为变更

  1. DevEco TDD助手:HarmonyOS 6的DevEco Studio内置了TDD工作流支持,包括红-绿-重构的状态指示、自动运行测试、测试和实现代码的快速切换。

  2. 保存自动运行:配置后,每次保存文件时自动运行相关测试,实时反馈红/绿状态,不需要手动触发。

  3. 安全重构:重命名方法、提取函数等重构操作时,IDE会自动调整对应的测试代码,减少重构时遗漏修改的风险。

适配代码

// DevEco Studio 6 TDD工作流配置
// .deveco/tdd-config.json
{
  "autoRunOnSave": true,
  "showStatusIndicator": true,
  "testFilePattern": "**/*Test.ets",
  "sourceFilePattern": "**/main/ets/**/*.ets",
  "quickFix": {
    "generateTestMethod": true,
    "generateImplementation": true
  }
}

// 使用快捷键:
// Ctrl+Shift+T: 在测试和实现之间跳转
// Ctrl+Shift+R: 运行当前测试
// Ctrl+Shift+E: 运行上次失败的测试
// Alt+Enter: 在红状态时生成实现代码

六、总结

维度 评价
学习难度 ⭐⭐⭐⭐
使用频率 ⭐⭐⭐
重要程度 ⭐⭐⭐⭐⭐

TDD的核心不是"先写测试"这个动作,而是红-绿-重构的循环纪律。红阶段迫使你思考需求,绿阶段迫使你写最简实现,重构阶段保证代码质量。三步缺一不可。落地TDD的最大障碍不是技术,是心态——"先写测试太慢了"的焦虑。但事实是:TDD不是慢,是把调试时间前置了。你不在写代码时花时间调试,因为测试已经帮你调了。长期来看,TDD产出的代码更少Bug、更好维护、更敢重构。TDD是一种投资,短期成本换来长期收益

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。