19、测试数据工厂与Fixture:让测试数据不再“手写”
写单元测试最烦什么?我个人觉得,不是写断言,也不是mock接口,而是——造数据。
你想想看,一个User对象可能有十几个字段,每次测试都要new一个,还得给每个字段赋值。更别提那些嵌套的对象了,Order里面套着List<Item>,Item里面又有Product……写几个测试下来,光造数据的代码就占了半屏。
今天我们就来聊聊怎么优雅地解决这个问题。说白了,就是让测试数据的创建变得自动化、可复用、好维护。
19.1 为什么需要数据工厂?
先看一个反面教材。我在项目中见过这样的测试代码:
@Test
fun testUserCreation() {
val user = User(
id = 1L,
name = "张三",
email = "zhangsan@example.com",
age = 25,
address = "北京市朝阳区",
phone = "13800138000",
avatar = "https://example.com/avatar.png",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
// ... 测试逻辑
}
这段代码有什么问题?
- 每个测试都要重复写这些字段
- 如果User类新增了一个字段,所有测试都得改
- 数据硬编码,可读性差
- 无法快速生成大量不同数据
嗯,这就是我们引入数据工厂的原因。
19.2 Builder模式:最基础的数据工厂
Builder模式是Kotlin中创建测试数据最自然的方式。Kotlin的命名参数和默认参数让这个模式写起来特别舒服。
我个人习惯这样设计:
data class User(
val id: Long,
val name: String,
val email: String,
val age: Int,
val address: String,
val phone: String,
val avatar: String,
val createdAt: Long,
val updatedAt: Long
)
// 测试数据工厂
object UserFactory {
fun create(
id: Long = 1L,
name: String = "测试用户",
email: String = "test@example.com",
age: Int = 25,
address: String = "测试地址",
phone: String = "13800138000",
avatar: String = "https://example.com/avatar.png",
createdAt: Long = System.currentTimeMillis(),
updatedAt: Long = createdAt
): User = User(
id = id,
name = name,
email = email,
age = age,
address = address,
phone = phone,
avatar = avatar,
createdAt = createdAt,
updatedAt = updatedAt
)
}
这样写的好处很明显:
- 默认值覆盖了大部分场景
- 测试中只需要覆盖关心的字段
- 新增字段时,只在工厂里加一个默认参数就行
使用起来是这样的:
@Test
fun testUserEmail() {
val user = UserFactory.create(email = "custom@example.com")
assertEquals("custom@example.com", user.email)
}
@Test
fun testUserAge() {
val user = UserFactory.create(age = 30)
assertTrue(user.age >= 18)
}
你看,测试代码干净多了。
create,而不是aUser或makeUser。这样在IDE里输入UserFactory.create时,自动补全更一致。
19.3 Fixture库:更高级的解决方案
当项目规模变大,手动写工厂类也开始变得繁琐。这时候就该Fixture库出场了。
Kotlin生态里比较流行的是kotlin-fixture。它可以根据类的结构自动生成随机数据。
先加依赖:
// build.gradle.kts
testImplementation("com.appmattus.fixture:fixture:1.3.0")
基本用法:
import com.appmattus.fixture.fixture
@Test
fun testAutoGenerate() {
val user: User = fixture()
println(user)
// 输出:User(id=42, name="qwerty", email="abc@def.ghi", ...)
}
是不是很爽?一行代码就生成了一个完整的User对象。
但问题来了——随机数据有时候太随机了。比如email字段可能生成"abc@def.ghi"这种格式,但你的业务逻辑可能要求邮箱必须是公司域名。
这时候可以自定义策略:
import com.appmattus.fixture.Fixture
import com.appmattus.fixture.generator.nextString
val customFixture = Fixture {
addStrategy(User::email) { "user${nextString(5)}@company.com" }
addStrategy(User::age) { 20..60 }
}
@Test
fun testCustomFixture() {
val user: User = customFixture.fixture()
assertTrue(user.email.endsWith("@company.com"))
assertTrue(user.age in 20..60)
}
我在项目中遇到过这样的情况:某个字段在数据库里是枚举类型,但Fixture库不认识,会生成随机字符串。这时候就需要手动注册枚举的生成策略。
19.4 随机数据生成:让测试更全面
随机数据的好处是能发现一些你没想到的边界情况。比如用户名长度、特殊字符、空值等。
Kotlin标准库里的Random类可以帮我们做基础的数据生成:
object RandomData {
private val random = Random
fun randomString(length: Int = 10): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
return (1..length).map { chars[random.nextInt(chars.length)] }.joinToString("")
}
fun randomEmail(): String {
val name = randomString(8).lowercase()
val domain = randomString(5).lowercase()
return "$name@$domain.com"
}
fun randomPhone(): String {
val prefix = listOf("138", "139", "150", "186", "188").random()
val suffix = (10000000..99999999).random()
return "$prefix$suffix"
}
fun randomAge(): Int = random.nextInt(18, 65)
}
结合工厂使用:
object UserFactory {
fun createRandom(): User = User(
id = Random.nextLong(1000, 9999),
name = RandomData.randomString(8),
email = RandomData.randomEmail(),
age = RandomData.randomAge(),
address = RandomData.randomString(20),
phone = RandomData.randomPhone(),
avatar = "https://example.com/avatar/${Random.nextLong()}.png",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
}
这样每次测试用的数据都不一样,能覆盖更多场景。
19.5 测试数据复用:避免重复造轮子
一个项目里,不同测试类可能都需要同样的基础数据。比如“一个已登录的用户”、“一个包含3件商品的订单”等。
我习惯把这些“模板数据”放在一个公共的TestData对象里:
object TestData {
val loggedInUser by lazy {
UserFactory.create(
id = 100L,
name = "已登录用户",
email = "logged@example.com"
)
}
val emptyCart by lazy {
Cart(
userId = 100L,
items = emptyList(),
totalPrice = 0.0
)
}
val sampleOrder by lazy {
Order(
id = 1L,
userId = 100L,
items = listOf(
Item(id = 1, name = "商品A", price = 29.9, quantity = 2),
Item(id = 2, name = "商品B", price = 49.9, quantity = 1)
),
totalPrice = 109.7,
status = OrderStatus.PAID
)
}
}
测试中直接引用:
@Test
fun testOrderTotal() {
val order = TestData.sampleOrder
assertEquals(109.7, order.totalPrice, 0.01)
}
@Test
fun testEmptyCartCheckout() {
val cart = TestData.emptyCart
assertThrows<IllegalStateException> {
checkoutService.checkout(cart)
}
}
这样做的最大好处是:数据定义在一处,修改也在一处。如果后来Order类增加了字段,只需要改TestData和UserFactory,所有测试自动适配。
19.6 数据工厂最佳实践
最后,总结一下我这些年积累的经验:
| 实践 | 说明 |
|---|---|
| 为每个实体类建一个工厂 | 不要把所有工厂塞到一个文件里,按实体类拆分 |
| 默认值要有业务意义 | 不要用空字符串或0作为默认值,用“测试用户”、“测试邮箱”这种 |
| 提供“最小有效”和“完整”两种构造 | 一个只填必填字段,一个填所有字段 |
| 随机数据要可控 | 固定种子或提供自定义策略,避免偶发失败 |
| 复用数据用lazy | 避免每个测试都重新创建,但要注意可变对象 |
| 不要过度抽象 | 如果工厂逻辑比测试本身还复杂,那就过度设计了 |
我曾经在一个项目里看到有人写了一个“万能数据工厂”,支持通过JSON配置生成任意对象。结果那个工厂本身就有2000行代码,还经常出bug。最后大家宁愿手写数据也不用它。
所以记住:数据工厂的目的是让测试更好写,而不是让工厂本身成为测试对象。