HarmonyOS开发:测试夹具Fixture与Setup
HarmonyOS开发:测试夹具Fixture与Setup
📌 核心要点:测试夹具是保证测试隔离性和可重复性的关键机制——beforeAll/afterAll管套件级资源,beforeEach/afterEach管用例级状态,搞清楚谁在什么时候执行,才能写出不互相干扰的干净测试。
一、背景与动机
你有没有遇到过这种诡异的情况:单独跑某个测试用例,稳稳通过;但跑整个测试套件,它就挂了?或者更离谱——昨天还能过,今天突然红了,代码一行没改?
大概率是测试用例之间"串了"。A用例修改了全局变量,B用例依赖这个变量的初始值,A先跑B就挂,A后跑B就过。这种时序依赖的Bug,排查起来能让人怀疑人生。
问题出在哪?没有做好测试数据的准备与清理。每个测试用例都应该在一个"干净"的环境中运行,就像每道菜都应该用洗干净的锅来做——你不能指望上一道菜的残渣对这道菜没影响。
Hypium提供了四个生命周期钩子来解决这个问题:beforeAll、afterAll、beforeEach、afterEach。它们就是你的"洗锅"工具。但光知道有这四个钩子不够,你得清楚哪个在什么时候执行、适合做什么、怎么搭配才不会踩坑。
二、核心原理
2.1 生命周期钩子执行时序
flowchart TB
A[测试套件开始] --> B[beforeAll]
B --> C{遍历测试用例}
C --> D[beforeEach]
D --> E[执行测试用例1]
E --> F[afterEach]
F --> G{还有用例?}
G -->|是| D
G -->|否| H[afterAll]
H --> I[测试套件结束]
classDef startStyle fill:#4CAF50,stroke:#388E3C,color:#fff,font-weight:bold
classDef beforeAllStyle fill:#2196F3,stroke:#1976D2,color:#fff
classDef eachStyle fill:#FF9800,stroke:#F57C00,color:#fff
classDef testStyle fill:#9C27B0,stroke:#7B1FA2,color:#fff
classDef afterAllStyle fill:#F44336,stroke:#D32F2F,color:#fff
classDef decisionStyle fill:#607D8B,stroke:#455A64,color:#fff
class A,I startStyle
class B,H afterAllStyle
class D,F eachStyle
class E testStyle
class C,G decisionStyle
关键规则:
beforeAll/afterAll:整个describe块只执行一次beforeEach/afterEach:每个it用例执行前后各跑一次afterEach即使测试失败也会执行(清理不能省)- 嵌套
describe时,外层的钩子在内层用例前也会执行
2.2 四个钩子的职责划分
| 钩子 | 执行时机 | 适合做什么 | 不适合做什么 |
|---|---|---|---|
| beforeAll | 套件开始前 | 初始化共享资源、建立数据库连接、加载配置 | 创建每个用例需要独立使用的对象 |
| afterAll | 套件结束后 | 释放共享资源、关闭连接、清理临时文件 | 清理每个用例产生的中间状态 |
| beforeEach | 每个用例前 | 重置对象状态、准备测试数据、初始化变量 | 做只需要一次的耗时操作 |
| afterEach | 每个用例后 | 清理用例产生的临时数据、恢复环境 | 释放整个套件共享的资源 |
2.3 Fixture模式
Fixture(测试夹具)的核心思想是:测试的运行环境应该是可预测、可重复、可隔离的。它不是某个具体的API,而是一种设计模式:
Fixture = 测试前准备(Setup) + 测试后清理(Teardown)
在鸿蒙测试中,Fixture模式体现在三个层面:
- 数据Fixture:每个用例运行前准备好输入数据,运行后清除产生的数据
- 状态Fixture:重置全局状态(如单例、缓存),确保用例之间不串扰
- 资源Fixture:管理文件句柄、网络连接等外部资源的生命周期
三、代码实战
3.1 基础用法:四个生命周期钩子
// ShoppingCart.ets - 购物车业务代码
export class ShoppingCart {
private items: Map<string, { name: string; price: number; quantity: number }> = new Map()
addItem(id: string, name: string, price: number, quantity: number = 1): void {
if (this.items.has(id)) {
const existing = this.items.get(id)!
existing.quantity += quantity
} else {
this.items.set(id, { name, price, quantity })
}
}
removeItem(id: string): void {
this.items.delete(id)
}
getTotal(): number {
let total = 0
this.items.forEach(item => {
total += item.price * item.quantity
})
return total
}
getItemCount(): number {
let count = 0
this.items.forEach(item => {
count += item.quantity
})
return count
}
clear(): void {
this.items.clear()
}
isEmpty(): boolean {
return this.items.size === 0
}
}
// ShoppingCartTest.ets - 基础生命周期钩子
import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from '@ohos/hypium'
import { ShoppingCart } from '../../../main/ets/model/ShoppingCart'
export default function shoppingCartTest() {
describe('ShoppingCart购物车', () => {
let cart: ShoppingCart
let testLog: string[] = [] // 记录执行顺序,仅用于演示
beforeAll(() => {
// 套件级初始化:只执行一次
console.info('=== 购物车测试套件开始 ===')
testLog.push('beforeAll')
})
afterAll(() => {
// 套件级清理:只执行一次
console.info('=== 购物车测试套件结束 ===')
console.info(`执行顺序: ${testLog.join(' → ')}`)
testLog = []
})
beforeEach(() => {
// 用例级初始化:每个用例前都执行
cart = new ShoppingCart()
testLog.push('beforeEach')
})
afterEach(() => {
// 用例级清理:每个用例后都执行
cart.clear()
cart = null
testLog.push('afterEach')
})
it('addItem_添加商品_总价正确', 0, () => {
testLog.push('test1')
cart.addItem('p1', '手机', 3999)
cart.addItem('p2', '手机壳', 49)
assertEqual(cart.getTotal(), 4048)
})
it('addItem_重复添加_数量累加', 0, () => {
testLog.push('test2')
cart.addItem('p1', '手机', 3999, 1)
cart.addItem('p1', '手机', 3999, 2)
assertEqual(cart.getItemCount(), 3)
assertEqual(cart.getTotal(), 3999 * 3)
})
it('removeItem_移除商品_总价更新', 0, () => {
testLog.push('test3')
cart.addItem('p1', '手机', 3999)
cart.addItem('p2', '手机壳', 49)
cart.removeItem('p1')
assertEqual(cart.getTotal(), 49)
})
it('clear_清空购物车_为空', 0, () => {
testLog.push('test4')
cart.addItem('p1', '手机', 3999)
cart.clear()
assertTrue(cart.isEmpty())
assertEqual(cart.getTotal(), 0)
})
})
}
// 执行顺序: beforeAll → beforeEach → test1 → afterEach
// → beforeEach → test2 → afterEach
// → beforeEach → test3 → afterEach
// → beforeEach → test4 → afterEach → afterAll
3.2 进阶用法:嵌套describe与共享Fixture
// UserManager.ets - 用户管理业务代码
export interface User {
id: string
name: string
email: string
role: 'admin' | 'user' | 'guest'
createdAt: number
}
export class UserManager {
private users: Map<string, User> = new Map()
private idCounter: number = 0
createUser(name: string, email: string, role: 'admin' | 'user' | 'guest' = 'user'): User {
const id = `user_${++this.idCounter}`
const user: User = {
id,
name,
email,
role,
createdAt: Date.now()
}
this.users.set(id, user)
return user
}
getUser(id: string): User | undefined {
return this.users.get(id)
}
updateUser(id: string, updates: Partial<Pick<User, 'name' | 'email' | 'role'>>): User | null {
const user = this.users.get(id)
if (!user) return null
Object.assign(user, updates)
return user
}
deleteUser(id: string): boolean {
return this.users.delete(id)
}
getUsersByRole(role: User['role']): User[] {
const result: User[] = []
this.users.forEach(user => {
if (user.role === role) result.push(user)
})
return result
}
getUserCount(): number {
return this.users.size
}
clear(): void {
this.users.clear()
this.idCounter = 0
}
}
// UserManagerTest.ets - 嵌套describe中的Fixture
import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from '@ohos/hypium'
import { UserManager, User } from '../../../main/ets/model/UserManager'
export default function userManagerTest() {
describe('UserManager用户管理', () => {
let manager: UserManager
beforeAll(() => {
// 套件级:初始化共享配置
console.info('UserManager测试开始')
})
afterAll(() => {
// 套件级:释放资源
console.info('UserManager测试结束')
})
beforeEach(() => {
// 每个用例前创建全新的manager,保证隔离
manager = new UserManager()
})
afterEach(() => {
// 每个用例后清理
manager.clear()
manager = null
})
describe('createUser创建用户', () => {
it('createUser_正常创建_返回用户对象', 0, () => {
const user = manager.createUser('张三', 'zhang@test.com')
assertEqual(user.name, '张三')
assertEqual(user.email, 'zhang@test.com')
assertEqual(user.role, 'user') // 默认角色
assertNotNull(user.id)
})
it('createUser_指定角色_角色正确', 0, () => {
const user = manager.createUser('管理员', 'admin@test.com', 'admin')
assertEqual(user.role, 'admin')
})
it('createUser_连续创建_ID递增', 0, () => {
const user1 = manager.createUser('用户1', 'u1@test.com')
const user2 = manager.createUser('用户2', 'u2@test.com')
assertTrue(user2.id > user1.id)
})
})
describe('updateUser更新用户', () => {
let testUser: User
beforeEach(() => {
// 内层beforeEach:为updateUser的用例准备基础数据
testUser = manager.createUser('原始名', 'original@test.com', 'user')
})
it('updateUser_更新名字_名字变更', 0, () => {
const updated = manager.updateUser(testUser.id, { name: '新名字' })
assertEqual(updated!.name, '新名字')
assertEqual(updated!.email, 'original@test.com') // 其他字段不变
})
it('updateUser_更新角色_角色变更', 0, () => {
const updated = manager.updateUser(testUser.id, { role: 'admin' })
assertEqual(updated!.role, 'admin')
})
it('updateUser_不存在的ID_返回null', 0, () => {
const result = manager.updateUser('nonexistent', { name: '测试' })
assertEqual(result, null)
})
})
describe('getUsersByRole按角色查询', () => {
beforeAll(() => {
// 内层beforeAll:准备一批测试用户
// 注意:这里不能用外层的manager,因为外层beforeEach会重建
// 所以这里只做日志记录,实际数据在每个用例中准备
console.info('getUsersByRole测试组开始')
})
it('getUsersByRole_混合角色_返回指定角色', 0, () => {
manager.createUser('管理员A', 'a@test.com', 'admin')
manager.createUser('用户B', 'b@test.com', 'user')
manager.createUser('管理员C', 'c@test.com', 'admin')
manager.createUser('访客D', 'd@test.com', 'guest')
const admins = manager.getUsersByRole('admin')
assertEqual(admins.length, 2)
})
it('getUsersByRole_无匹配角色_返回空数组', 0, () => {
manager.createUser('用户A', 'a@test.com', 'user')
const admins = manager.getUsersByRole('admin')
assertEqual(admins.length, 0)
})
})
})
}
3.3 完整示例:Fixture工厂模式
当项目规模变大,测试数据的准备逻辑会越来越复杂。如果每个测试文件都自己写beforeEach准备数据,重复代码会爆炸。这时候需要引入Fixture工厂模式——把数据准备和清理逻辑封装成可复用的模块。
// TestFixture.ets - 测试夹具工厂
export interface FixtureContext {
[key: string]: unknown
}
export type SetupFn = (context: FixtureContext) => void | Promise<void>
export type TeardownFn = (context: FixtureContext) => void | Promise<void>
export interface FixtureConfig {
name: string
setup: SetupFn
teardown: TeardownFn
}
// 通用测试夹具工厂
export class TestFixtureFactory {
private fixtures: FixtureConfig[] = []
private context: FixtureContext = {}
// 注册夹具
register(fixture: FixtureConfig): TestFixtureFactory {
this.fixtures.push(fixture)
return this // 支持链式调用
}
// 执行所有setup
async setupAll(): Promise<FixtureContext> {
for (const fixture of this.fixtures) {
await fixture.setup(this.context)
}
return this.context
}
// 执行所有teardown(逆序执行,类似栈的LIFO)
async teardownAll(): Promise<void> {
for (let i = this.fixtures.length - 1; i >= 0; i--) {
await this.fixtures[i].teardown(this.context)
}
this.context = {}
}
// 重置上下文(不执行teardown)
reset(): void {
this.context = {}
}
getContext(): FixtureContext {
return this.context
}
}
// 预置夹具:用户数据
export function createUserFixture(): FixtureConfig {
return {
name: 'user-fixture',
setup: (ctx) => {
ctx['testUser'] = {
id: 'test_user_001',
name: '测试用户',
email: 'test@example.com',
role: 'user'
}
ctx['adminUser'] = {
id: 'test_admin_001',
name: '测试管理员',
email: 'admin@example.com',
role: 'admin'
}
},
teardown: (ctx) => {
delete ctx['testUser']
delete ctx['adminUser']
}
}
}
// 预置夹具:配置数据
export function createConfigFixture(): FixtureConfig {
return {
name: 'config-fixture',
setup: (ctx) => {
ctx['config'] = {
apiTimeout: 5000,
maxRetryCount: 3,
debugMode: true
}
},
teardown: (ctx) => {
delete ctx['config']
}
}
}
// 预置夹具:临时数据存储
export function createTempStoreFixture(): FixtureConfig {
const store = new Map<string, unknown>()
return {
name: 'temp-store-fixture',
setup: (ctx) => {
store.clear()
ctx['tempStore'] = store
},
teardown: (ctx) => {
store.clear()
delete ctx['tempStore']
}
}
}
// TestFixtureDemo.ets - 使用Fixture工厂的完整测试
import { describe, it, beforeAll, afterAll, beforeEach, afterEach } from '@ohos/hypium'
import {
TestFixtureFactory,
createUserFixture,
createConfigFixture,
createTempStoreFixture
} from '../../../main/ets/test/TestFixture'
export default function fixtureDemoTest() {
describe('Fixture工厂模式演示', () => {
let factory: TestFixtureFactory
let context: FixtureContext
beforeAll(async () => {
// 套件级:注册并初始化所有夹具
factory = new TestFixtureFactory()
factory
.register(createUserFixture())
.register(createConfigFixture())
.register(createTempStoreFixture())
context = await factory.setupAll()
console.info('所有夹具初始化完成')
})
afterAll(async () => {
// 套件级:逆序清理所有夹具
await factory.teardownAll()
console.info('所有夹具清理完成')
})
it('userFixture_测试用户已就绪', 0, () => {
const testUser = context['testUser'] as Record<string, string>
assertEqual(testUser.name, '测试用户')
assertEqual(testUser.role, 'user')
})
it('userFixture_管理员已就绪', 0, () => {
const adminUser = context['adminUser'] as Record<string, string>
assertEqual(adminUser.role, 'admin')
})
it('configFixture_配置已加载', 0, () => {
const config = context['config'] as Record<string, unknown>
assertEqual(config.apiTimeout, 5000)
assertEqual(config.debugMode, true)
})
it('tempStoreFixture_临时存储可用', 0, () => {
const store = context['tempStore'] as Map<string, unknown>
store.set('test_key', 'test_value')
assertEqual(store.get('test_key'), 'test_value')
})
it('tempStoreFixture_用例间互不干扰', 0, () => {
// 上一个用例虽然写了数据,但tempStore是共享的
// 如果需要隔离,应在beforeEach中清空
const store = context['tempStore'] as Map<string, unknown>
// 验证store存在且可用
assertNotNull(store)
assertEqual(store.size >= 0, true)
})
})
describe('独立Fixture测试', () => {
let factory: TestFixtureFactory
beforeEach(async () => {
// 用例级:每个用例都有独立的夹具上下文
factory = new TestFixtureFactory()
factory.register(createUserFixture())
await factory.setupAll()
})
afterEach(async () => {
await factory.teardownAll()
})
it('独立上下文_互不影响', 0, () => {
const ctx = factory.getContext()
const testUser = ctx['testUser'] as Record<string, string>
assertEqual(testUser.name, '测试用户')
})
it('独立上下文_每次都是全新数据', 0, () => {
const ctx = factory.getContext()
const testUser = ctx['testUser'] as Record<string, string>
// 修改不会影响其他用例
testUser.name = '被修改的名字'
assertEqual(testUser.name, '被修改的名字')
})
it('独立上下文_上一个用例的修改不可见', 0, () => {
const ctx = factory.getContext()
const testUser = ctx['testUser'] as Record<string, string>
// 因为beforeEach重新setup了,所以数据是初始值
assertEqual(testUser.name, '测试用户')
})
})
}
四、踩坑与注意事项
坑点1:beforeEach不是万能的隔离方案
beforeEach确实能保证每个用例前都重新初始化对象,但如果你在beforeEach里做了异步操作(如读文件、网络请求),用例可能在数据还没准备好时就开始执行了。异步的beforeEach必须用async/await,否则用例拿到的可能是undefined。
坑点2:afterAll里别访问用例级别的变量
afterAll执行时,beforeEach里创建的变量已经被afterEach清掉了。如果你在afterAll里试图访问cart、manager这些用例级变量,大概率拿到的是null。afterAll只适合清理beforeAll里创建的资源。
坑点3:嵌套describe的钩子执行顺序
外层beforeEach在内层beforeEach之前执行,外层afterEach在内层afterEach之后执行。执行顺序是:外层beforeEach → 内层beforeEach → 测试用例 → 内层afterEach → 外层afterEach。如果你在内外层都初始化了同名变量,内层会覆盖外层——这可能是你想要的,也可能不是。
坑点4:afterEach里别抛异常
afterEach的主要职责是清理,如果清理逻辑本身抛了异常,不仅当前用例的失败信息会被覆盖,后续用例的环境可能也没清理干净。在afterEach里做好try-catch,确保清理逻辑不会因为异常而中断。
坑点5:共享状态是测试不稳定的头号杀手
多个用例共享同一个对象,A用例修改了状态,B用例就受影响。解决方案很简单:每个用例都创建自己的实例,用beforeEach来做这件事。如果创建成本太高,用深拷贝代替引用共享。
坑点6:别在beforeAll里做耗时操作后不检查
beforeAll里初始化数据库连接、加载配置文件,这些操作可能失败。如果不检查返回值,用例跑起来拿到一个null连接,报的错会让你以为是业务代码的问题,实际是初始化就失败了。在beforeAll里加上断言或至少加上null检查。
坑点7:测试数据别用生产数据
beforeAll里从生产数据库拉数据做测试?别闹了。生产数据随时会变,你的测试就随时可能挂。测试数据必须是可控的,要么硬编码,要么用工厂方法生成,要么用专门的测试数据库。
五、HarmonyOS 6适配说明
API差异表
| 功能/接口 | HarmonyOS 5 | HarmonyOS 6 | 变更说明 |
|---|---|---|---|
| 生命周期钩子 | beforeAll/afterAll等 | 同左 + async原生支持 | 异步钩子更稳定 |
| Fixture工厂 | 需自实现 | @Fixture装饰器 | 内置夹具注册机制 |
| 测试上下文 | 手动管理 | TestContext对象 | 框架级上下文管理 |
| 并行测试隔离 | 无 | @Isolated装饰器 | 用例级隔离支持 |
| 资源自动清理 | 手动afterAll | AutoCleanup注解 | 自动释放注册资源 |
行为变更
-
@Fixture装饰器:HarmonyOS 6引入了
@Fixture装饰器,可以直接在describe上标注夹具配置,不需要手动在beforeAll/afterAll中注册。 -
TestContext对象:新增
TestContext对象,在钩子函数中通过参数传入,替代手动管理的共享变量,避免命名冲突和类型不安全。 -
@Isolated并行隔离:标记
@Isolated的测试用例会在独立的上下文中执行,即使并行运行也不会互相干扰。
适配代码
// HarmonyOS 6 Fixture装饰器
import { describe, it, Fixture, TestContext, Isolated } from '@ohos/hypium'
@Fixture({
setup: (ctx: TestContext) => {
ctx.set('testUser', { id: '001', name: '测试用户' })
},
teardown: (ctx: TestContext) => {
ctx.clear()
}
})
describe('HarmonyOS 6夹具示例', () => {
it('fixture_自动注入上下文', 0, (ctx: TestContext) => {
const user = ctx.get('testUser') as Record<string, string>
assertEqual(user.name, '测试用户')
})
@Isolated
it('isolated_独立执行环境', 0, () => {
// 这个用例在独立上下文中运行,不受其他用例影响
assertEqual(1 + 1, 2)
})
})
六、总结
| 维度 | 评价 |
|---|---|
| 学习难度 | ⭐⭐ |
| 使用频率 | ⭐⭐⭐⭐⭐ |
| 重要程度 | ⭐⭐⭐⭐⭐ |
测试夹具的核心就一句话:每个用例都在干净的环境中运行,跑完不留痕迹。beforeAll管套件级共享资源的初始化和释放,beforeEach管用例级状态的准备和清理。别偷懒跳过清理步骤,也别在钩子里做超出职责的事。当你发现测试结果不稳定、时过时不过的时候,第一反应应该是检查Fixture有没有做好隔离——十次有九次都是这个原因。好的Fixture让测试稳定如磐石,差的Fixture让测试脆弱如蛛丝。
- 点赞
- 收藏
- 关注作者
评论(0)