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,而不是aUsermakeUser。这样在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库不认识,会生成随机字符串。这时候就需要手动注册枚举的生成策略。

注意:Fixture库虽然方便,但不要滥用。对于核心业务逻辑的测试,我建议还是用显式的工厂方法,因为数据可读性更重要。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()
    )
}

这样每次测试用的数据都不一样,能覆盖更多场景。

经验之谈:随机数据测试有个坑——测试可能因为随机数据而偶发失败。比如随机生成了一个超长字符串,导致数据库插入失败。我的做法是:在CI里固定随机种子,这样每次运行的数据都一样,但本地开发时可以随机。

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类增加了字段,只需要改TestDataUserFactory,所有测试自动适配。

19.6 数据工厂最佳实践

最后,总结一下我这些年积累的经验:

实践 说明
为每个实体类建一个工厂 不要把所有工厂塞到一个文件里,按实体类拆分
默认值要有业务意义 不要用空字符串或0作为默认值,用“测试用户”、“测试邮箱”这种
提供“最小有效”和“完整”两种构造 一个只填必填字段,一个填所有字段
随机数据要可控 固定种子或提供自定义策略,避免偶发失败
复用数据用lazy 避免每个测试都重新创建,但要注意可变对象
不要过度抽象 如果工厂逻辑比测试本身还复杂,那就过度设计了

我曾经在一个项目里看到有人写了一个“万能数据工厂”,支持通过JSON配置生成任意对象。结果那个工厂本身就有2000行代码,还经常出bug。最后大家宁愿手写数据也不用它。

所以记住:数据工厂的目的是让测试更好写,而不是让工厂本身成为测试对象

一句话总结:用Builder模式做基础,用Fixture库提效率,用随机数据扩覆盖,用公共数据促复用。别过度设计,保持简单。
测试数据工厂知识体系 测试数据工厂 Builder模式 Fixture库 随机数据生成 数据复用 默认参数 命名参数 工厂方法 自动生成 自定义策略 类型适配 字符串/数字 邮箱/电话 固定种子 公共数据 lazy初始化 模板数据
公众号:蓝海资料掘金营,微信deep3321