当前位置: 首页 > 科技观察

单元测试框架比较

时间:2023-03-16 11:13:44 科技观察

作者|高跃翔在我们日常的TDD开发中,总是免不了要写测试。对于Java程序员来说,JUnit似乎是一个显而易见的选择。它确实是一个非常好的工具,可以帮助我们在大多数情况下完成测试工作。但是在开发过程中,我发现JUnit并不总是那么好用。在某些情况下,编写令人满意的测试需要付出很多努力。JUnit不擅长什么一个令人满意的测试应该能够清楚地反映测试的目标、测试的目的、测试的输入输出,并且应该遵循DRY原则来减少测试的重复作为尽可能多。JUnit可以通过设计测试方法名和组织方法中的代码来明确表达意图,也可以通过参数化测试来减少相同测试目的的代码重复。但它在这些地方都做得不够好。清楚地表达测试的意图阐明测试的意图在使用JUnit时并不总是可以做到的。这主要体现在两个方面。(1)如何命名测试方法第一个体现是用Java编写测试时,用什么命名风格来命名测试。选择驼峰大小写是为了代码风格统一吗?还是选择下划线以获得更高的可读性?这个问题在不同的项目中有不同的做法,似乎没有统一的认识。这个问题的根源在于JUnit的测试名是一个Java方法名,而Java的方法名不能在其中插入空格。所以除了下面要介绍的两种测试工具,使用Kotlin写JUnit也是一种方式。(2)如何组织方法中的代码第二个表现是JUnit对于测试方法内部如何编写没有强制规定。这意味着我可以在测试中任意组织代码,例如在一个测试方法中多次调用一个方法并验证每次的结果,或者将调用测试目录的逻辑与准备数据和验证逻辑混合.一起。总之,这样做的结果就是测试方法中的代码组织很奇怪。每当我阅读别人编写的测试时,总是需要几分钟才能弄清楚代码在做什么。对于这个问题,我个人选择使用注释来标记given,when,then,并为IDEA设置了一个livetemplate,方便插入。这不是一个不可用的参数化测试。如果对于不能明确表达测试意图的问题还有一些workaround可以绕过的话,JUnit只是一个可用的参数化测试函数,并没有什么好的绕过方法。JUnit提供了各种Source注解来为参数化测试提供数据,但是这个功能太弱了,不能令人满意。第一个不满意的原因是各种Source注解基本上只支持7种基本类型加上String、Enum和Class类型。如果要使用其他类型的实例作为参数,则必须使用MethodSource或ArgumentsSource注解。这就引出了第二个原因:这两个注解需要分别写一个静态方法或者一个ArgumentProvider实现,这样就很难在测试代码旁边写测试参数。Arguments.of()方法不适合读取测试参数。这两点导致测试的可读性下降。根据“测试就是文档”的原则,我们应该尽量保证测试的可读性。第三个原因来自ParameterizedTest注解。它的名称字段可以使用参数的索引值将参数填充到模板中,生成更具可读性的测试名称。但它的功能仅限于此。因为这个模板只能使用索引值,不能使用索引再调用里面的方法或者字段。所以如果我们的参数是一个复杂的对象,就必须重写toString方法才能得到满意的输出。但这违反了编写测试的原则之一——你不能添加用于测试的实现代码。如果我们必须得到一个更具表现力的测试名称,添加一个专用的测试参数也可以做到。但是这会引起IDE或构建工具的警告,因为他们认为这个参数没有被使用。总之,JUnit虽然可以解决大部分的问题,但是在这么几个小地方就没有那么完美了。那么有没有什么工具可以作为JUnit的替代品呢?当然有。下面我将按照接触的先后顺序介绍这两个测试框架。可以在GitHub上找到以下示例的完整代码。使用Spock作为测试框架Spock是一个用Groovy编写的测试框架。它按照given/when/then的结构来定义dsl,可以让测试更加语义化。它的主要特性之一是数据驱动测试,这使得编写参数化测试变得容易。我已经尝试在两个项目中使用Spock作为测试框架,并且遇到了一些我无法解决的问题。如何使用Spock让我们看一个简单的例子:classMarsRoverSpockTestextendsSpecification{//1def"当火星探测器报告时应该返回火星探测器的位置和方向"(){//2given://3.1defmarsRover=MarsRoverFixture.buildMarsRover(position:newPosition(1,2),direction:Direction.EAST,)when://3.2defmarsRoverInfo=marsRover.report()然后://3.3marsRoverInfo.position==newPosition(1,2)marsRoverInfo.direction==Direction.EAST}}(1)每个测试都需要继承抽象类Specification;(2)可以使用字符串来命名测试;(3)Spock定义了一些block,wheregiven,when,then都是一个block。当块可以是任何代码时,给定的块负责测试的设置工作,但最好是对测试目标的调用。它总是与then块一起出现以进行断言。这里不需要任何断言,写一个返回值为boolean的表达式即可。Spock有一个非常友好的测试报告输出。如果我们刻意纠正上面的断言,我们可以得到这样的测试输出:Conditionnotsatisfied:movedMarsRover.position==movedPosition|||||||位置(x=-2,y=2)||假|Position(x=-1,y=2)MarsRover(position=Position(x=-1,y=2),direction=WEST)在这个输出中,我们可以清楚的看到表达式两端的值是什么都是,非常方便调试。特点(1)使用字符串命名测试方法在前面的例子中,我们可以看到测试方法的名称是使用字符串命名的,不需要像JUnit那样遵循Java方法的命名规则。这样我们就不用纠结用什么命名方式了,只需要像写一句话一样写测试方法名即可。(2)语义结构在前面的例子中,我们看到了块的概念。它可以帮助我们更好地组织代码结构,编写更具可读性的代码。其实在每一个块声明之后,我们还可以加上一个字符串来达到注释的作用。例如:给定:“1,2位置的火星探测器,方向为北”除了上面的例子,Spock还提供了三个block:cleanup,expect,where。有关详细信息,请参阅其文档。因为Spock强制使用块来组织测试代码,这迫使我们编写更易于阅读的结构化代码。(3)利用数据表构造参数化测试对于参数化测试,我们再看一个例子。def“应该将火星探测器从位置1,2向前移动到#movedPosition.x,#movedPosition.y,当方向为#direction并且移动长度为#length”(){给定:defmarsRover=MarsRoverFixture.buildMarsRover(position:newPosition(1,2),direction:direction,)when:defmovedMarsRover=marsRover.forward(length)then:movedMarsRover.position==movedPositionmovedMarsRover.direction==direction其中:方向|长度||移动位置Direction.EAST|2||新位置(3,2)Direction.WEST|3||新位置(-2,2)Direction.SOUTH|-1||新位置(1,3)Direction.NORTH|1||newPosition(1,3)}我们可以看到最后一段代码是一个where块,也就是Spock中定义数据测试的数据的地方。例子中的写法称为数据表。虽然Spock支持一些其他的写法,但我个人认为数据表是一种更易读的写法,所以这是我最常使用的写法。而且这种写法不需要手动调整格式,IDEA支持自动格式,堪称完美。我们还可以注意方法名称。方法名中有几个以#开头的字符串,实际上是指数据表中定义的变量。这种通过变量名引用的方法比JUnit的索引值方法可读性要好得多。而我们可以看到像#movedPosition.x这样的表达式,它们可以直接使用这些对象中的字段值来生成方法名,而不需要依赖对象的toString方法。对于断言失败的测试,会像上例一样在测试输出中打印具体数据,方便定位测试失败的用例。与JUnit相比,Spock的数据表不仅将参数列表和测试方法放在一起,还支持任意类型的参数,并且可以定义无副作用的参数名,解决了JUnit参数化测试的所有痛点。(4)简洁的断言在上面的例子中,我们可以看出Spock的断言非常简洁。你不需要像使用assertj那样写一个很长的assertThat(xxx).isEqualTo(yyy)。您只需要一个返回布尔值OK的表达式。甚至可以将多行断言提取到返回其AND运算结果的方法中。(5)使用Groovy编写测试使用Groovy编写测试可以说是优点也是缺点。优点是Groovy是一种动态语言,我们可以利用这个特性在测试中编写不那么冗长的代码。而且在前面的例子中,assertion中获取到的marsRover.position字段本身就是一个private字段,测试依然可以正常执行。这些都是Groovy带来的灵活性。缺点是这是一种相对小众的语言。如果不是Gradle,可能没有多少人会熟悉它的语法。这也会导致人们在选择时变得更加谨慎。(6)与IDE的完美结合我一直使用的IDEA与其完美适配。除了前面提到的格式数据表,最重要的是IDEA可以像JUnit一样执行,不需要任何配置。缺点在我第一个项目中使用Spock的时候,我几乎没有发现它有什么缺点,以至于在后来的项目中,我总是问TL是否可以将它添加到项目中。但是当我尝试在Kotlin项目中使用它时,我遇到了一些问题。与Kotlin的集成问题:(1)无法识别Kotlin的语法糖Groovy无法直接识别Kotlin代码中的各种语法糖,这让编写测试有点难受。就像命名参数一样。事实上,Groovy也支持命名参数,只是语法与Kotlin不同。这显得有些尴尬。这个问题可以通过编写一些像测试代码这样的fixutre来解决。例如下面的Kotlin类型:dataclassMarsRover(privatevalposition:Position,privatevaldirection:Direction,)我们可以为它写一个fixture,我们也可以在测试中使用命名参数:classMarsRoverFixture{@NamedVariantstaticMarsRoverbuildMarsRover(position,direction){newMarsRover(position,direction)}}其他一些语法问题基本可以绕过。本质思想是把要测试的代码想象成编译好的Java,这样你就可以找到绕过它的方法。(2)没有对finalclass的mock支持这是一个无法回避的问题。Kotlin中的类型默认是最终的,不能被继承。但是Spock的模拟需要被模拟对象类型的子类。这可以防止我们嘲笑这些类型。其实这个问题并不是Spock特有的,Mockito也有这个问题。只是在使用JUnit的时候,我们会选择使用MockK作为Kotlin项目的mock工具,而不是Mockito。有几种策略可以解决这个问题:尽量不要mock。这就需要我们设计更容易测试的代码,这样我们就可以避免在测试中使用mock。因为需要继承Spring组件,所以会使用Kotlin全开放编译器来提供对Spring的支持,我们可以mock这些Spring组件。写这篇文章的时候,发现了一个很久没有更新的仓库kotlin-test-runner。也许你可以借鉴这里的思路来解决这个问题。(3)与JUnit的兼容对于之前的问题,我们当时还有一个workaround,就是使用JUnit5+MockK来编写需要mocks的测试。但是当时的Kotlin版本还比较低,与JUnit没有兼容性问题。兼容性问题是JUnit在编写Spring集成测试时,如果需要mockbeans,需要在springmock中使用@MockkBean注解。但是从kotlin1.5.30开始,这个库不兼容Spock写的Spring集成测试,会出现NPE问题。使用Kotlin反映Specification子类时会出现此问题。这个问题直到Kotlin1.6.20-M1才得到修复。Groovy语言学习成本:如前所述,使用Groovy确实有一些学习成本。如果团队中没有熟悉的人,可能要走一点弯路。使用Kotest作为测试框架Kotest是一个偶然发现的测试框架,并没有在实际项目中实践过。所以这里我只能分享如何使用,并没有经验分享。如何使用Kotest让我们看一个例子:classMarsRoverKotestTest:BehaviorSpec({given("amarsrover"){valmarsRover=MarsRover(position=Position(1,2),direction=Direction.EAST,)`when`("itreportinformation"){val(position,direction)=marsRover.report()then("getit'spositionanddirection"){positionshouldBePosition(1,2)directionshouldBeDirection.EAST}}}})这是ABDD风格的测试。Kotest使用BehaviorSpec类进行封装。然后,我们没有看到通常的assertThat()语句,取而代之的是Kotest的断言库提供的方法。特点(1)丰富的测试风格支持除了上面的例子,我们还有很多测试风格可以选择,在它的文档中有介绍:TestingStyles|科测。在这些样式中,测试名称被写为字符串。如前所述,我们不必像使用JUnit那样担心测试方法的命名风格,只需描述测试的目的即可。然而,除了BDD测试风格之外,其他测试风格都没有对测试代码的组织强加任何强制性要求。这需要团队就测试代码的组织和维护达成一致。这与JUnit没有什么不同。(2)对数据驱动测试的支持Kotest提供扩展来支持数据驱动测试。当然,不使用这个扩展也可以做到,比如用列表构造数据后用foreach创建测试。但是,在这里的例子中,我们仍然使用这个扩展来进行演示。classMarsRoverKotestTest:FunSpec({context("datatest"){withData(nameFn={(direction,length,position)->"shouldmovemarsroverfrom1,2to${position.x},${position.y}当方向为$direction且移动长度为$length"},Triple(Direction.EAST,2,Position(3,2)),Triple(Direction.WEST,3,Position(-2,2)),Triple(Direction.SOUTH,-1,Position(1,3)),Triple(Direction.NORTH,1,Position(1,3)),){(direction,length,movedPosition)->valmarsRover=MarsRover(位置=位置(1,2),direction=direction,)valmovedMarsRover=marsRover.forward(length)movedMarsRover.report().positionshouldBemovedPositionmovedMarsRover.report().directionshouldBedirection}}})虽然这个数据驱动测试是相对于Spock的数据表没有那么直观,但与JUnit相比,能够方便地自定义测试方法名,支持任意类型的参数,测试数据和测试代码可以放在一起,是一个巨大的进步。(3)简洁的断言在上面的例子中我们可以看到,Kotest提供了自己的断言库,所以不需要编写像assertThat()这样冗长的语句。(4)Kotlin编写,可与Kotlin项目完美结合。使用Kotlin编写测试,你可以在Kotlin中使用各种语法糖。这样你就不必像Spock那样为语法切换而苦恼。(5)支持MockK同样,因为Kotest的测试是用Kotlin写的,自然支持MockK。这样就可以利用MockK的特性来支持final类的mocking。(6)兼容JUnit因为Kotest是基于JUnit平台的,所以兼容JUnit,不会出现像上面Spock那样的问题。缺点由于没有在实际项目中实践过,所以很多缺点至今没有发现。(1)IDEA和Gradle的集成还不够完善。该问题的表现是单个测试方法无法在IDEA中执行。但是仔细研究后发现与gradle的集成还不够好。默认情况下,IDEA使用gradle来执行测试。执行单个测试的命令是gradletest--tests"xxx.Class.yyyMethod"。对于JUnit来说,这里的类和方法是非常直观的类名和方法名。但是写Kotest不是为了在类中写方法,而是为了调用方法生成测试。所以gradle这个命令是没办法生效的,没办法只执行一个测试方法。更新IDEA的配置使用IDEA跑测试后,在mac上可以正常执行单个测试方法。不要使用Spek前面介绍了两个值得尝试的测试框架,这里再介绍一个不推荐的框架。一开始就想试试这个框架,因为看到有网友说这是Kotlin版的Spock。但是在实践中,我并没有发现它有和Spock相似的功能,同时也出现了这些痛点:与其他测试框架混合使用的问题:与其他测试框架混合使用时,Spek测试总是先执行。即使我们只想在IDEA中执行一个JUnit单元测试,Spek也会在执行我们要执行的测试之前运行它自己的所有测试。这意味着在编写了大量的Speck测试之后,需要等待很长时间才能执行其他测试。无法编写Spring集成测试。我在写demo的时候,发现它的IDEA插件在Windows下无法运行。如果你能忍受这些痛点,那么我不推荐使用这个框架,毕竟上面已经有更好的选择了。总结现在我们有两个选项来测试JUnit以外的框架。当然它们并不完美,JUnit仍然是最稳定和风险最小的。但是如果你想尝试这两个框架,可以考虑这几个方面:(1)生产代码的编程语言:如果是Kotlin,那么可以考虑Kotest,不要考虑Spock如果是Java,那么这两个是值得考虑(2)语言熟悉度:Kotlin显然比Groovy更受欢迎。从这个角度来说,Kotest是更好的选择(3)测试框架的受欢迎程度(我不知道这方面有什么评价标准,仅供参考):两个GitHub上每个框架的star数几乎是同样,一个是3.1k,一个是3.2k(JUnit只有4.9k)。在MVNRepository上,Spock的使用率明显高于Kotest(4)IDEA集成:Spock在这方面没有问题。需要安装KotestPlugins,需要配置才能运行单测(5Gradle集成:Spock完美集成Kotest无法执行单测