本文已参与「新人创作礼」活动,一起开启掘金创作之路。
kfactories
kfactories是一系列工厂和通用程序的集合, 用来运行高效的智能monkey和基于属性的测试策略.
一言以蔽之, monkey要生成实体及其多种状态, 就必须完全理解各种不同的实体及状态. 通过轮转所有的值, 它们聚焦于确保系统对于合法且已知的状态变更和行为能够像预期的一样鲁棒.
举个例子: 消费者能够拥有名, 姓, 邮箱地址和电话号码, 并且至少一个字段是必须的. 当你运行测试的时候, 你将总是会得到表示合法消费者的唯一组合. 比如, 首次运行可能设置了消费者的全部字段. 再次运行可能只设置了邮箱.
动机
在Square, 我们使用拥有复杂状态的资源和对象.
当你消费API的时候, 对于这些对象上存在什么内容不存在什么内容, 你可能会做些假设. 比如, 上面涉及的消费者资源的例子, 如果你的测试总是使用带有固定集合名字字段的消费者的静态视图, 那么你的测试将永远不会针对有效消费者的所有不同变体进行检查.
这样的话, 测试将不能够完全覆盖边缘用例. 资源也能够被嵌套. 举个例子: 消费者能够拥有卡片和地址, 每一个字段都有自己的假设集合.
给定所有这些不同状态的话, 你怎么写测试才能使你验证自己代码库中没有bug? 这就是智能monkey和基于属性测试要来拯救的地方. 它们知道每一个资源的合法状态, 并且能够设置所有的状态.
如果假设变更的话(举个例子: 名字先前是必须的, 现在不再是了), 通过在相应的消费者工厂中修改这些假设, 我们将能够探测到需要调整代码的其它地方.
例子
上面所描述问题的最常见场景在下面: 创建模拟值并传递给后面的函数. 这个模拟并不知晓对象上面的哪个属性是可选的.
fun sendEmail(customer: Customer, message: String) {
emailService.sendEmail(customer.getEmail(), message);
}
@Test fun sendEmailSuccess() {
val customer = Customer.toBuilder()
.setFirstName("Michaël")
.setEmail("michael@gmail.com")
.build();
sendEmail(customer, "Hello!");
assertThat(emailService.emailSentTo(customer.getEmail())).isTrue;
}
更新代码以在to字段包含用户的名. 代码可以按照下面这样修改:
fun sendEmail(customer: Customer, message: String) {
emailService.sendEmail(customer.getFirstName(), customer.getEmail(), message);
}
尽管原先的测试已经通过了, 这产生了这样一种期待, 即名是得到保证的, 但这可能是不准确的, 而且会导致严重的生产事故.
用法
基本数据类型
在讨论对象之前, 先让我们讨论一下基础数据类型. kfactories提供了一些基础数据类型你可以使用:
newBoolean: 返回true或者false.newInt(min=MIN_INT, max=MAX_INT): 返回处于min和max之间的整型.newInt(n..m): 返回给定范围内的整型.newLong(min=MIN_LONG, max=MAX_LONG): 返回处于min和max之间的长整型.newLong(n..m): 返回给定范围内的长整型.newString(n..m): 使用n到m字符创建字符串.
通用程序
maybe
给定一个对象, 返回该对象或者null. 该特性在判断资源上的一个字段是否可选时十分有用.
例子:
val customer = Customer(
id = newCustomerId(),
phone = maybe(newPhoneNumber())
)
生成器
IntRange扩展用来创建介于n和m之间的值. 在创建值列表时这将十分有用. 举个例子: 一个类有20到25个学生:
val students = 20..25.gen { newStudent() }
抽取值
有2个方法可用来抽取值: pluck(从给定集合中抽象单个值)和pluckMany(返回一个或者多个值).
// returns one student from the list.
val student = pluck(students)
// returns one student from the passed-in values.
val student = pluck(student1, student2, student3)
// returns between 1 and 2 students from the list.
val students = pluckMany(1..2, students)
// returns between 1 and 2 from the passed in students.
val students = pluckMany(
1..2, student1, student2, student3, student4, student5, student6
)
使用回调
有2个方法可使用回调: applyOne, 应用给定列表中的其中一个回调, 和applyMany,应用给定列表中的其中一个或者多个回调. 在处理有多个维度的对象时这将十分有用, 而这些维度可能依赖于其它(想一下来自proto的oneOf).
// The student will either have a name, or an email or a phone.
val student = Student.Builder().applyOne(
{ it.setName(newStudentName()) },
{ it.setEmail(newStudentEmail()) },
{ it.setPhone(newStudentPhone()) }
).build()
// The student will have at least two fields set and a max of 4.
val student = Student.Builder().applyMany(
2..4,
{ it.setName(newStudentName()) },
{ it.setEmail(newStudentEmail()) },
{ it.setPhone(newStudentPhone()) },
{ it.setGrade(newGrade()) },
{ it.setCity(newStudentCity()) },
).build()
推荐
尽管这是一个库, 你可以按照自己的需求使用它, 但在我们的工厂遵循一系列规则和通用结构之后, 发现代码库变得更加可访问和可维护.
规则
- 代码库中的每一个对象必须有一个工厂. 它帮助你和未来的维护者不必猜测该对象的合法表示.
- 每个工厂方法必须要小. 在实践中, 这意味着嵌套对象也必须有它们自己的工厂.
- 工厂应该很容易识别并且益寿延年可重复的模型. 我们推荐在所有工厂方法之前使用前缀
new. 比如newCustomer,newInt,newLong.
工厂结构
基于合法对象的表示, 我们使用命名参数来设置默认生成器, 并且我们允许调用者在他们觉得合适的地方覆盖这些参数. 另一种方法是让工厂完成工作, 然后使用copy覆盖字段值.
fun newCustomer(
id: CustomerId = newCustomerId(),
fullName: String? = maybe(newCustomerFullName()),
phone: PhoneNumber? = maybe(newCustomerPhoneNumber()),
email: EmailAddress = newCustomerEmail()
) = Customer(
id = id,
fullName = fulName,
phone = phone,
email = email
)
这给予了调用者机会去覆盖他们关注的东西, 同时也没有影响其它的字段也不必复制生成的数据对象. 举个例子, 如果测试要求提供电话号码:
val customer = newCustomer(phone = newCustomerPhoneNumber())
或者不要求电话号码:
val customer = newCustomer(phone = null)