简而言之,本章涵盖了各种单元测试断言技术。 它详细说明了内置机制, Hamcrest匹配器和AssertJ断言的优缺点 。 正在进行的示例扩大了该主题,并说明了如何创建和使用自定义匹配器/断言。
单元测试断言
信任但要验证
罗纳德·里根(Ronald Reagan)
岗位测试结构解释了为什么单元测试通常分阶段进行。 它澄清说, 真正的测试即结果验证在第三阶段进行。 但是到目前为止,我们只看到了一些简单的示例,主要使用了JUnit的内置机制。
如Hello World所示,验证基于错误类型AssertionError
。 这是编写所谓的自检测试的基础。 单元测试断言将谓词评估为true
或false
。 如果为false
,则抛出AssertionError
。 JUnit运行时捕获此错误并将测试报告为失败。
以下各节将介绍三种较流行的单元测试断言变体。
断言
JUnit的内置断言机制由类org.junit.Assert
。 它提供了两种静态方法来简化测试验证。 以下代码片段概述了可用方法模式的用法:
fail();
fail( "Houston, We've Got a Problem." );assertNull( actual );
assertNull( "Identifier must not be null.",actual );assertTrue( counter.hasNext() );
assertTrue( "Counter should have a successor.",counter.hasNext() );assertEquals( LOWER_BOUND, actual );
assertEquals( "Number should be lower bound value.", LOWER_BOUND,actual );
-
Assert#fail()
无条件地引发断言错误。 这对于标记不完整的测试或确保引发了预期的异常可能很有帮助(另请参见“ 测试结构”中的“预期异常”部分)。 -
Assert#assertXXX(Object)
用于验证变量的初始化状态。 为此,存在两个称为assertNull(Object)
和assertNotNull(Object)
。 -
Assert#assertXXX(boolean)
方法测试boolean参数传递的预期条件。 调用assertTrue(boolean)
期望条件为true
,而assertFalse(boolean)
期望相反。 -
Assert#assertXXX(Object,Object)
和Assert#assertXXX(value,value)
方法用于对值,对象和数组进行比较验证。 尽管结果没有区别,但通常的做法是将期望值作为第一个参数,将实际值作为第二个参数。
所有这些类型的方法都提供带有String
参数的重载版本。 如果发生故障,此参数将合并到断言错误消息中。 许多人认为这有助于更清楚地指定失败原因。 其他人则认为这样的消息混乱,使测试更难以阅读。
乍一看,这种单元测试断言似乎很直观。 这就是为什么我在前面的章节中使用它进行入门的原因。 此外,它仍然非常流行,并且工具很好地支持故障报告。 但是,在需要更复杂谓词的断言的表达性方面也受到一定限制。
Hamcrest
Hamcrest是一个旨在提供用于创建灵活的意图表达的API的库。 该实用程序提供了称为Matcher
的可嵌套谓词。 这些允许以某种方式编写复杂的验证条件,许多开发人员认为比布尔运算符更易于阅读。
MatcherAssert
类支持单元测试断言。 为此,它提供了静态的assertThat(T, Matcher
)方法。 传递的第一个参数是要验证的值或对象。 第二个谓词用于评估第一个谓词。
assertThat( actual, equalTo( IN_RANGE_NUMBER ) );
如您所见,匹配器方法模仿自然语言的流程以提高可读性。 以下代码片段更加清楚了此意图。 这使用is(Matcher
)方法来修饰实际的表达式。
assertThat( actual, is( equalTo( IN_RANGE_NUMBER ) ) );
MatcherAssert.assertThat(...)
存在另外两个签名。 首先,有一个采用布尔参数而不是Matcher
参数的变量。 它的行为与Assert.assertTrue(boolean)
。
第二个变体将一个附加的String
传递给该方法。 这可以用来提高故障消息的表达能力:
assertThat( "Actual number must not be equals to lower bound value.", actual, is( not( equalTo( LOWER_BOUND ) ) ) );
在失败的情况下,给定验证的错误消息看起来像这样:
Hamcrest带有一组有用的匹配器。 图书馆在线文档的“常见匹配项”部分列出了最重要的部分。 但是对于特定于域的问题,如果有合适的匹配器,通常可以提高单元测试断言的可读性。
因此,该库允许编写自定义匹配器。
让我们返回教程的示例来讨论该主题。 首先,我们对该场景进行调整以使其更合理。 假设NumberRangeCounter.next()
返回的是RangeNumber
类型,而不是简单的int
值:
public class RangeNumber {private final String rangeIdentifier;private final int value;RangeNumber( String rangeIdentifier, int value ) {this.rangeIdentifier = rangeIdentifier;this.value = value;}public String getRangeIdentifier() {return rangeIdentifier;}public int getValue() {return value;}
}
我们可以使用自定义匹配器来检查NumberRangeCounter#next()
的返回值是否在计数器定义的数字范围内:
RangeNumber actual = counter.next();assertThat( actual, is( inRangeOf( LOWER_BOUND, RANGE ) ) );
适当的自定义匹配器可以扩展抽象类TypeSafeMatcher<T>
。 该基类处理null
检查和类型安全。 可能的实现如下所示。 请注意,它如何添加工厂方法inRangeOf(int,int)
以便于使用:
public class InRangeMatcher extends TypeSafeMatcher<RangeNumber> {private final int lowerBound;private final int upperBound;InRangeMatcher( int lowerBound, int range ) {this.lowerBound = lowerBound;this.upperBound = lowerBound + range;}@Overridepublic void describeTo( Description description ) {String text = format( "between <%s> and <%s>.", lowerBound, upperBound );description.appendText( text );}@Overrideprotected void describeMismatchSafely(RangeNumber item, Description description ){description.appendText( "was " ).appendValue( item.getValue() );}@Overrideprotected boolean matchesSafely( RangeNumber toMatch ) {return lowerBound <= toMatch.getValue() && upperBound > toMatch.getValue();}public static Matcher<RangeNumber> inRangeOf( int lowerBound, int range ) {return new InRangeMatcher( lowerBound, range );}
}
对于给定的示例,工作量可能会有些夸大。 但它显示了如何使用自定义匹配器消除先前帖子中有些神奇的IN_RANGE_NUMBER
常量。 除了新类型外,还强制声明语句的编译时类型安全。 这意味着例如String
参数将不被接受进行验证。
下图显示了使用自定义匹配器的测试结果失败的样子:
很容易看出describeTo
和describeMismatchSafely
的实现以哪种方式影响故障消息。 它表示期望值应该在指定的下限和(计算的)上限1之间 ,并跟在实际值之后。
不幸的是,JUnit扩展了其Assert
类的API以提供一组assertThat(…)方法。 这些方法实际上复制了MatcherAssert
提供的API。 实际上,这些方法的实现委托给这种类型的相应方法。
尽管这似乎是一个小问题,但我认为值得一提。 由于这种方法,JUnit牢固地与Hamcrest库绑定在一起。 这种依赖性有时会导致问题。 特别是与其他库一起使用时,通过合并自己的hamcrest版本的副本,情况更糟……
Hamcrest的单元测试主张并非没有竞争。 尽管关于每次测试一个确定与每个测试 一个概念的讨论超出了本文的讨论范围,但后一种观点的支持者可能认为该库的验证声明过于嘈杂。 尤其是当一个概念需要多个断言时。
这就是为什么我必须在本章中添加另一部分!
断言
在“ 测试跑步者”中,示例片段之一使用了两个assertXXX
语句。 这些验证期望的异常是IllegalArgumentException
的实例并提供特定的错误消息。 该段看起来像这样:
Throwable actual = ...assertTrue( actual instanceof IllegalArgumentException );
assertEquals( EXPECTED_ERROR_MESSAGE, actual.getMessage() );
上一节教我们如何使用Hamcrest改进代码。 但是,如果您碰巧是该库的新手,您可能会想知道要使用哪个表达式。 或打字可能会感到不舒服。 无论如何,多个assertThat
语句会加在一起。
AssertJ库努力通过为Java提供流畅的断言来改善这一点。 流畅的接口 API的目的是提供一种易于阅读的,富有表现力的编程风格,从而减少胶合代码并简化键入。
那么如何使用这种方法来重构上面的代码?
import static org.assertj.core.api.Assertions.assertThat;
与其他方法类似,AssertJ提供了一个实用程序类,该类提供了一组静态的assertThat
方法。 但是这些方法针对给定的参数类型返回特定的断言实现。 这就是所谓的语句链接的起点。
Throwable actual = ...assertThat( actual ).isInstanceOf( IllegalArgumentException.class ).hasMessage( EXPECTED_ERROR_MESSAGE );
旁观者认为可读性在一定程度上可以扩展,但无论如何都可以用更紧凑的样式来写断言。 了解如何流畅地添加与被测特定概念相关的各种验证方面。 这种编程方法支持有效的类型输入,因为IDE的内容辅助可以提供给定值类型的可用谓词列表。
因此,您想向后世提供表现力的失败消息吗? 一种可能性是使用describedAs
作为链中的第一个链接来注释整个块:
Throwable actual = ...assertThat( actual ).describedAs( "Expected exception does not match specification." ).hasMessage( EXPECTED_ERROR_MESSAGE ).isInstanceOf( NullPointerException.class );
该代码段期望使用NPE,但假设在运行时抛出了IAE。 然后失败的测试运行将提供如下消息:
也许您希望根据给定的失败原因使您的消息更加细微。 在这种情况下,您可以在每个验证规范之前添加一条describedAs
语句:
Throwable actual = ...assertThat( actual ).describedAs( "Message does not match specification." ).hasMessage( EXPECTED_ERROR_MESSAGE ).describedAs( "Exception type does not match specification." ).isInstanceOf( NullPointerException.class );
还有更多的AssertJ功能可供探索。 但是,要使这篇文章保持在范围之内,请参阅实用程序的在线文档以获取更多信息。 但是,在结束之前,让我们再次看一下范围内验证示例。 这是可以通过自定义断言解决的方法:
public class RangeCounterAssertionextends AbstractAssert<RangeCounterAssertion, RangeCounter>
{private static final String ERR_IN_RANGE_OF = "Expected value to be between <%s> and <%s>, but was <%s>";private static final String ERR_RANGE_ID = "Expected range identifier to be <%s>, but was <%s>";public static RangeCounterAssertion assertThat( RangeCounter actual ) {return new RangeCounterAssertion( actual );}public InRangeAssertion hasRangeIdentifier( String expected ) {isNotNull();if( !actual.getRangeIdentifier().equals( expected ) ) {failWithMessage( ERR_RANGE_ID, expected, actual.getRangeIdentifier() );}return this;}public RangeCounterAssertion isInRangeOf( int lowerBound, int range ) {isNotNull();int upperBound = lowerBound + range;if( !isInInterval( lowerBound, upperBound ) ) {int actualValue = actual.getValue();failWithMessage( ERR_IN_RANGE_OF, lowerBound, upperBound, actualValue );}return this;}private boolean isInInterval( int lowerBound, int upperBound ) {return actual.getValue() >= lowerBound && actual.getValue() < upperBound;}private RangeCounterAssertion( Integer actual ) {super( actual, RangeCounterAssertion.class );}
}
自定义断言是扩展AbstractAssert
常见做法。 第一个通用参数是断言的类型本身。 流利的链接样式需要它。 第二种是断言所基于的类型。
该实现提供了两种附加的验证方法,可以按照以下示例进行链接。 因此,这些方法将返回断言实例本身。 请注意, isNotNull()
的调用如何确保我们要在其上进行断言的实际RangeNumber
不为null
。
定制断言由其工厂方法assertThat(RangeNumber)
。 因为它继承了可用的基本检查,所以断言可以开箱即用地验证非常复杂的规范。
RangeNumber first = ...
RangeNumber second = ...assertThat( first ).isInRangeOf( LOWER_BOUND, RANGE ).hasRangeIdentifier( EXPECTED_RANGE_ID ).isNotSameAs( second );
为了完整RangNumberAssertion
,以下是RangNumberAssertion
的实际运行方式:
不幸的是,不可能在同一测试用例中将两种不同的断言类型与静态导入一起使用。 当然,假定这些类型遵循assertThat(...)
命名约定。 为了避免这种情况,文档建议扩展实用程序类Assertions
。
这样的扩展可用于提供静态的assertThat
方法,作为所有项目自定义断言的入口。 通过在整个项目中使用此自定义实用程序类,不会发生导入冲突。 在为所有断言提供单一入口点的部分中,可以找到详细的描述:在线文档中有关定制断言的 yours + AssertJ 。
流利的API的另一个问题是单行链接的语句可能更难调试。 这是因为调试器可能无法在链中设置断点。 此外,可能不清楚哪个方法调用已引起异常。
但是,正如Wikipedia所说的那样,可以通过将语句分成多行来克服这些问题,如上面的示例所示。 这样,用户可以在链中设置断点,并轻松地逐行逐步执行代码。
结论
简而言之,JUnit的这一章介绍了不同的单元测试断言方法,例如该工具的内置机制,Hamcrest匹配器和AssertJ断言。 它概述了一些优缺点,并通过本教程的进行中示例对主题进行了扩展。 此外,还展示了如何创建和使用自定义匹配器和断言。
尽管基于Assert
的机制肯定是过时的并且不太面向对象,但它仍然具有它的提倡者。 Hamcrest匹配器将断言和谓词定义完全分开,而AssertJ断言以紧凑且易于使用的编程风格进行评分。 所以现在您选择太多了……
请注意,这将是本教程有关JUnit测试要点的最后一章。 这并不意味着没有更多要说的了。 恰恰相反! 但这将超出此迷你系列量身定制的范围。 您知道他们在说什么: 总是让他们想要更多…
- 嗯,我想知道区间边界是否会比下限和范围更直观...
翻译自: https://www.javacodegeeks.com/2014/09/junit-in-a-nutshell-unit-test-assertion.html