为什么普通测试会撒谎,而你还相信它们
想象一下:你写了一个函数,用三个单元测试覆盖了它。绿色勾号。CI通过。生产环境——出错了。熟悉吗?
经典单元测试的问题在于它们只检查特定的例子。你自己想出了输入数据,又自己编写了预期结果。这就像一场考试,学生自己出题。迟早你会遗漏一个边界情况——null、空列表、负数或包含Unicode字符的字符串。
这时,基于属性的测试(property-based testing)登场了。与其编写“如果传入5,应该返回25”,你不如制定一个属性:“对于任何整数,其平方总是非负的”。然后框架会自动生成数百个随机值并检查这个属性。
在本文中,我们将探讨如何在Kotlin中使用Kotest库进行基于属性的测试。你将学习如何编写可靠的测试、在早期阶段发现错误,以及不再害怕重构。这不仅仅是枯燥的理论——我们将编写可以直接应用于项目的真实代码。
什么是基于属性的测试,为什么选择Kotlin?
这个想法并不新鲜——早在1999年,Haskell的QuickCheck库就推广了它。但直到现在,随着后端系统和微服务复杂性的增加,基于属性的测试才成为开发者技术栈中的必备工具。
Kotlin是实现这种方法的理想语言。为什么?
- 函数式特性:Lambda表达式、扩展函数、不可变性——所有这些都简化了纯属性的编写。
- Kotest是一个强大的框架,原生支持基于属性的测试、生成器和Shrinking(最小化失败示例的算法)。
- 与Java的兼容性:你可以使用相同的方法测试用Java编写的遗留代码。
让我们直接进入正题。下面是一个将颠覆你对测试认知的例子。
5分钟内完成第一个基于属性的测试
假设我们有一个检查字符串是否为回文的函数:
fun isPalindrome(s: String): Boolean { val cleaned = s.filter { it.isLetterOrDigit() }.lowercase() return cleaned == cleaned.reversed()}普通测试看起来像这样:
@Testfun `test known palindromes`() { assertTrue(isPalindrome("A man, a plan, a canal: Panama")) assertTrue(isPalindrome("racecar")) assertFalse(isPalindrome("hello"))}一切正常。但是,如果输入数据包含空格、数字或符号呢?如果字符串是空的呢?如果只包含空格呢?
现在,让我们使用Kotest编写一个基于属性的测试:
import io.kotest.core.spec.style.StringSpecimport io.kotest.property.forAllimport io.kotest.property.arbitrary.string
class PalindromePropertyTest : StringSpec({ "任何经过清理的字符串,其自身应该是回文" { forAll<String> { original -> val cleaned = original.filter { it.isLetterOrDigit() }.lowercase() isPalindrome(cleaned) == true } }})这里发生了什么?forAll<String>生成随机字符串:空字符串、带空格的、带Unicode的、带数字的。然后对每个字符串检查属性。如果任何一个字符串违反了断言,测试就会失败,并且Kotest会显示最小的可重现示例。
在这个阶段,你已经可以发现错误。例如,如果字符串只包含空格——filter会返回一个空字符串,而isPalindrome("")会返回true。普通测试可能会漏掉这个情况。