java单元测试中的断言
为测试编写断言似乎很简单:我们所需要做的就是将结果与期望进行比较。 通常使用测试框架提供的断言方法(例如assertTrue()或assertEquals())来完成此操作。 但是,在更复杂的测试场景中,使用这样的基本断言来验证测试的结果可能会很尴尬。
主要的问题是,通过使用它们,我们用低级的细节使测试难以理解。 这是不希望的。 在我看来,我们应该争取以商业语言进行测试。
在本文中,我将展示如何使用所谓的“匹配器库”并实现我们自己的自定义断言,以使我们的测试更具可读性和可维护性。
为了演示的目的,我们将考虑以下任务:让我们想象一下,我们需要为应用程序的报告模块开发一个类,当给定两个日期(“ begin”和“ end”)时,该类将提供一个小时的时间这些日期之间的间隔。 然后使用时间间隔从数据库中获取所需的数据,并以精美图表的形式将其呈现给最终用户。
让我们以一种写断言的“标准”方式开始。 我们可以在这个示例中使用JUnit ,尽管我们可以同样使用TestNG 。 我们将使用诸如assertTrue(),assertNotNull()或assertSame()之类的断言方法。
下面介绍了属于HourRangeTest类的几个测试之一。 这很简单。 首先,它要求getRanges()方法返回同一天两个日期之间的所有一小时范围。 然后,它验证返回的范围是否完全正确。
private final static SimpleDateFormat SDF
= new SimpleDateFormat("yyyy-MM-dd HH:mm");
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
final List ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
assertEquals(3, ranges.size());
assertEquals(SDF.parse("2012-07-23 12:00").getTime(), ranges.get(0).getStart());
assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(0).getEnd());
assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(1).getStart());
assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(1).getEnd());
assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(2).getStart());
assertEquals(SDF.parse("2012-07-23 15:00").getTime(), ranges.get(2).getEnd());
}
这绝对是有效的测试; 但是,它有一个严重的缺点。 // then部分中有很多重复的片段。 显然,它们是使用复制和粘贴创建的,因此经验告诉我,这不可避免地会导致错误。 此外,如果我们要编写更多这样的测试(并且我们当然应该编写更多测试以验证HourlyRange类!),则相同的断言语句将在每个语句中反复重复。
断言的数量过多会削弱当前测试的可读性,但每个断言的复杂性也会削弱当前测试的可读性。 有很多低级噪声,这无助于掌握测试的核心场景。 众所周知,代码的读取次数要比编写的次数要多(我认为这对于测试代码也适用),因此,我们绝对应该提高可读性。
在重写测试之前,我还想强调另一个弱点,这一次是与出现错误时得到的错误消息有关。 例如,如果getRanges()方法返回的范围之一的时间与预期的时间不同,那么我们将了解以下内容:
org.junit.ComparisonFailure:
Expected :1343044800000
Actual :1343041200000
此消息不是很清楚,肯定可以改进。
那么,我们到底可以做些什么? 好吧,最明显的是将断言提取到私有方法中:
private void assertThatRangeExists(List ranges, int rangeNb,
String start, String stop) throws ParseException {
assertEquals(ranges.get(rangeNb).getStart(), SDF.parse(start).getTime());
assertEquals(ranges.get(rangeNb).getEnd(), SDF.parse(stop).getTime());
}
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
final List ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
assertEquals(ranges.size(), 3);
assertThatRangeExists(ranges, 0, "2012-07-23 12:00", "2012-07-23 13:00");
assertThatRangeExists(ranges, 1, "2012-07-23 13:00", "2012-07-23 14:00");
assertThatRangeExists(ranges, 2, "2012-07-23 14:00", "2012-07-23 15:00");
}
现在好点了吗? 我会这样说。 减少了重复代码的数量,并提高了可读性。 这绝对是好的。
这种方法的另一个优点是,我们现在处于更好的位置,可以改进在验证失败时打印的错误消息。 断言代码被提取为一种方法,因此我们可以轻松地通过更具可读性的错误消息来增强断言。
将这些断言方法放到某些基类中可以促进重用这些方法,我们的测试类将需要扩展这些基类。
不过,我认为我们可能会做得更好:使用私有方法有一些缺点,随着测试代码的增长,这些缺点变得更加明显,然后这些私有方法将在许多测试方法中使用:
所有这些意味着从长远来看,在私有断言方法的帮助下,我们将遇到一些测试的可读性和可维护性问题。 让我们寻找没有这些缺点的另一种解决方案。
在继续之前,让我们了解一些新工具。 如前所述,JUnit或TestNG提供的断言不够灵活。 在Java世界中,至少有两个满足我们要求的开源库: AssertJ (FEST Fluent Assertions项目的一个分支)和Hamcrest 。 我喜欢第一个,但这是一个品味问题。 两者看起来都非常强大,并且都允许一个人获得类似的效果。 与Hamcrest相比,我更喜欢AssertJ的主要原因是,IDE完全支持基于流畅接口的AssertJ API。
AssertJ与JUnit或TestNG的集成非常简单。 您要做的就是添加所需的导入,停止使用测试框架提供的默认断言,并开始使用AssertJ提供的断言。
AssertJ提供了许多现成的有用断言。 它们都共享相同的“模式”:它们以assertThat()方法开始,该方法是Assertions类的静态方法。 此方法将被测试的对象作为参数,并“设置阶段”以进行进一步的验证。 随后是真正的断言方法,它们中的每一种都可以验证测试对象的各种属性。 让我们看几个例子:
assertThat(myDouble).isLessThanOrEqualTo(2.0d);
assertThat(myListOfStrings).contains("a");
assertThat("some text")
.isNotEmpty()
.startsWith("some")
.hasLength(9);
从这里可以看出,AssertJ提供了比JUnit或TestNG更丰富的断言集。 而且,您可以将它们链接在一起–如最后一个assertThat(“ some text”)示例所示。 一个非常方便的事情是,您的IDE将根据被测试对象的类型找出可能的方法,并向您提示,仅提出适合的方法。 因此,例如,对于双精度变量,在键入assertThat(myDouble)之后。 并按CTRL + SPACE(或IDE提供的任何快捷方式),将为您提供isEqualTo(expectedDouble),isNegative()或isGreaterThan(otherDouble)之类的方法列表-对于双值验证而言都是有意义的。 这实际上很酷。
由AssertJ或Hamcrest提供一组更强大的断言是很好的,但这并不是我们在HourRange类的情况下真正想要的。 匹配器库的另一个功能是,它们允许您编写自己的断言。 这些自定义断言的行为将与AssertJ的默认断言完全相同-即,您将能够将它们链接在一起。 这正是我们下一步将改善测试的方法。
我们将在一分钟内看到自定义断言的示例实现,但是现在让我们看一下我们将要实现的最终效果。 这次,我们将使用(我们自己的)RangeAssert类的assertThat()方法。
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
List ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
RangeAssert.assertThat(ranges)
.hasSize(3)
.isSortedAscending()
.hasRange("2012-07-23 12:00", "2012-07-23 13:00")
.hasRange("2012-07-23 13:00", "2012-07-23 14:00")
.hasRange("2012-07-23 14:00", "2012-07-23 15:00");
}
自定义断言的某些优点甚至可以在上述示例中看到。 关于此测试,首先要注意的是// then部分肯定变小了。 现在它也很可读。
当将其应用于较大的代码库时,其他优势将体现出来。 如果我们继续使用我们的自定义断言,我们会注意到:
与私有断言方法相比,自定义断言的唯一缺点是您必须投入更多的工作来创建它们。 让我们看一下我们的自定义断言的代码,以判断这是否真的是一项艰巨的任务。
要创建自定义断言,我们应该扩展AssertJ的AbstractAssert类或它的许多子类之一。 如下所示,我们的RangeAssert扩展了AssertJ的ListAssert类。 这是有道理的,因为我们希望我们的自定义断言可以验证范围列表(List
用AssertJ编写的每个自定义断言都包含负责创建断言对象和注入测试对象的代码,因此可以对其执行进一步的方法。 如清单所示,构造函数和静态assertThat()方法都将List
public class RangeAssert extends ListAssert {
protected RangeAssert(List ranges) {
super(ranges);
}
public static RangeAssert assertThat(List ranges) {
return new RangeAssert(ranges);
}
现在让我们看看RangeAssert类的其余部分。 hasRange()和isSortedAscending()方法(如下清单所示)是自定义断言方法的典型示例。 它们具有以下属性:
private final static SimpleDateFormat SDF
= new SimpleDateFormat("yyyy-MM-dd HH:mm");
public RangeAssert isSortedAscending() {
isNotNull();
long start = 0;
for (int i = 0; i < actual.size(); i++) {
Assertions.assertThat(start)
.isLessThan(actual.get(i).getStart());
start = actual.get(i).getStart();
}
return this;
}
public RangeAssert hasRange(String from, String to) throws ParseException {
isNotNull();
Long dateFrom = SDF.parse(from).getTime();
Long dateTo = SDF.parse(to).getTime();
boolean found = false;
for (Range range : actual) {
if (range.getStart() == dateFrom && range.getEnd() == dateTo) {
found = true;
}
}
Assertions
.assertThat(found)
.isTrue();
return this;
}
}
那错误消息呢? AssertJ使我们可以轻松添加它。 在简单的情况下,例如值的比较,通常使用as()方法就足够了,如下所示:
Assertions
.assertThat(actual.size())
.as("number of ranges")
.isEqualTo(expectedSize);
如您所见,as()只是AssertJ框架提供的另一种方法。 现在,当测试失败时,它将打印以下消息,以便我们立即知道出了什么问题:
org.junit.ComparisonFailure: [number of ranges]
Expected :4
Actual :3
有时,我们不仅需要被测试对象的名称,还可以了解发生了什么。 让我们采用hasRange()方法。 如果在发生故障时我们可以打印所有范围,那将是非常好的。 可以使用overridingErrorMessage()方法完成此操作,如下所示:
public RangeAssert hasRange(String from, String to) throws ParseException {
...
String errMsg = String.format("ranges\n%s\ndo not contain %s-%s",
actual ,from, to);
...
Assertions.assertThat(found)
.overridingErrorMessage(errMsg)
.isTrue();
...
}
现在,如果发生故障,我们将获得非常详细的错误消息。 它的内容取决于Range类的toString()方法。 例如,它可能看起来像这样:
HourlyRange{Mon Jul 23 12:00:00 CEST 2012 to Mon Jul 23 13:00:00 CEST 2012},
HourlyRange{Mon Jul 23 13:00:00 CEST 2012 to Mon Jul 23 14:00:00 CEST 2012},
HourlyRange{Mon Jul 23 14:00:00 CEST 2012 to Mon Jul 23 15:00:00 CEST 2012}]
do not contain 2012-07-23 16:00-2012-07-23 14:00
在本文中,我们讨论了许多编写断言的方法。 我们基于测试框架提供的断言从“传统”方式开始。 在许多情况下,这已经足够好了,但是正如我们所看到的,它有时缺乏表达测试意图所需的灵活性。 接下来,我们通过引入私有断言方法对情况进行了一些改进,但这也不是理想的解决方案。 在最后的尝试中,我们引入了用AssertJ编写的自定义断言,并获得了更具可读性和可维护性的测试代码。
如果我要向您提供有关断言的建议,我将提出以下建议:如果您停止使用测试框架(例如JUnit或TestNG)提供的断言并切换到匹配器库(例如AssertJ)提供的断言,则可以极大地改善您的测试代码。或Hamcrest)。 这将使您能够使用范围广泛的可读性很强的断言,并且无需在测试的// then部分中使用复杂的语句(例如,对集合进行循环)。
即使编写自定义断言的成本很小,也不必仅仅因为可以就引入它们。 当测试代码的可读性和/或可维护性受到威胁时,请使用它们。 根据我的经验,我鼓励您在以下情况下引入自定义断言:
我的经验告诉我,使用单元测试,您几乎不需要自定义断言。 但是,我敢肯定,在集成和端到端(功能)测试的情况下,它们将是不可替代的。 它们使我们的测试可以使用领域的语言(而不是实现的语言)来讲,并且它们还封装了技术细节,从而使我们的测试更易于更新。
翻译自: https://www.infoq.com/articles/custom-assertions/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1
java单元测试中的断言