HarmonyOS开发: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在测试和实现间切换 |
行为变更
-
DevEco TDD助手:HarmonyOS 6的DevEco Studio内置了TDD工作流支持,包括红-绿-重构的状态指示、自动运行测试、测试和实现代码的快速切换。
-
保存自动运行:配置后,每次保存文件时自动运行相关测试,实时反馈红/绿状态,不需要手动触发。
-
安全重构:重命名方法、提取函数等重构操作时,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是一种投资,短期成本换来长期收益。
- 点赞
- 收藏
- 关注作者
评论(0)