每个用例编写一到二个断言是单元测试最佳实践的常见内容. 那些这么认为的是极少和只展示一个单元测试的人。因此如果你采纳他们的建议,为一个很小的运算你都需要大量的单元测试去保证质量。这篇文章意图通过例子展示,一个测试用例多个断言是有必要和有价值的。
Person 这个对象在数据绑定场景中经常出现,我们来看下。
测试 FirstName
第一个来测试 FirstName 这个属性的设置,开始如下:
[<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set() { <span color="#0000ff">var</span>person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); person.FirstName = <span color="#c0504d">"Bob"</span>; <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Bob"</span>, person.FirstName); }
接下来我们来测试 FirstName 的改变通知。
[<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_PropertyChanged() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> eventAssert = <span color="#0000ff">new</span> Granite.Testing.<span color="#4bacc6">PropertyChangedEventAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; eventAssert.Expect(<span color="#c0504d">"FirstName"</span>); }
当我们执行这个测试时,会得到一个失败提示信息“期望的属性名‘FirstName’,但接收到的是’IsChanged’”。显然,设置 FirstName 的属性触发了“IsChanged”标记,我们需要把它考虑在内。因此我们把它加入:
[<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_PropertyChanged() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> eventAssert = <span color="#0000ff">new</span> Granite.Testing.<span color="#4bacc6">PropertyChangedEventAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; eventAssert.SkipEvent(); <span color="#008000">//this was IsChanged</span> eventAssert.Expect(<span color="#c0504d">"FirstName"</span>); }
鉴于以上两个测试,我们考虑当 FirstName 被修改时还有其他什么属性会改变。查看 API,IsChanged 和 FullName 属性会变化。
[TestMethod]
<span color="#0000ff">public void</span> Person_FullName_Changed_By_Setting_FirstName() { <span color="#0000ff">var</span> person =<span color="#0000ff"> new</span><span color="#4bacc6"> Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); person.FirstName = <span color="#c0504d">"Bob"</span>; <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Bob Smith"</span>, person.FullName); } [<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_IsChanged_Changed_By_Setting_FirstName() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); person.FirstName = <span color="#c0504d">"Bob"</span>; <span color="#4bacc6">Assert</span>.IsTrue(person.IsChanged); }
当然,如果这些属性改变了,我们需要获取到属性改变通知:
[TestMethod]
<span color="#0000ff">public void</span> Person_IsChanged_Property_Change_Notification_By_Setting_FirstName() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> eventAssert = <span color="#0000ff">new </span><span color="#4bacc6">PropertyChangedEventAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; eventAssert.Expect(<span color="#c0504d">"IsChanged"</span>); } [<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FullName_Property_Change_Notification_By_Setting_FirstName() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> eventAssert =<span color="#0000ff"> new</span> <span color="#4bacc6">PropertyChangedEventAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; eventAssert.SkipEvent(); <span color="#008000">//this was IsChanged</span> eventAssert.SkipEvent(); <span color="#008000">//this was FirstName</span> eventAssert.Expect(<span color="#c0504d">"FullName"</span>); }<br></br>
接下来两个测试针对 HasErrors 这个属性和 ErrorsChanged 事件。
[TestMethod]
<span color="#0000ff">public void</span> Person_FirstName_Set_HasErrorsIsFalse() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); person.FirstName = <span color="#c0504d">"Bob"</span>; <span color="#4bacc6">Assert</span>.IsFalse(person.HasErrors); } [<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_ErrorsChanged_Did_Not_Fire() { <span color="#0000ff">var </span>person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> errorsChangedAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ErrorsChangedEventAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; errorsChangedAssert.ExpectNothing(); }
目前我们有 8 个测试了,这意味着当我们修改 FirstName 的属性值,我们要考虑会发生改变的每件事。但是这不算完。我们还需要确保没有别的会被意外改变。理论上说,这意味着更多的断言和相当数量的测试,但是,接下来我们采用取巧的方法,用 ChangeAssert 方法来替代 HasErrors 测试。
[TestMethod]
<span color="#0000ff">public void</span> Person_FirstName_Set_Nothing_Unexpected_Changed() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> changeAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ChangeAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; changeAssert.AssertOnlyChangesAre(<span color="#c0504d">"FirstName"</span>, <span color="#c0504d">"FullName"</span>, <span color="#c0504d">"IsChanged"</span>); }<br></br>
ChangeAssert 简单地通过映射获取对象的状态,因此,稍后你可以断言到除了你指出的几个具体属性其他的没变。
恭喜,你完成了你的第一个测试用例。完成一个,还有很多很多等着。
为什么说是“一个”测试用例?
那 8 个测试只是完成了覆盖 FirstName 属性从“Adam”修改成“Bob”这一个场景,在其他的值没有在错误状态、LastName 不为 null 或空的情况下。让我们看看测试用例的完整清单:
- 将 FirstName 值设置为“Adam”
- 将 FirstName 值设置为 null
- 将 FirstName 设为空串
- 在 LastName 值为 null 的情况下,执行 case1-3
- 在 LastName 为空串的情况下,执行 case1-3
- 在 FirstName 值以 null 开头的情况下,执行 case1-5
- 在 FirstName 值以空串开头的情况下,执行 case1-5
目前我们看到了 27 个不同的场景。如果每个场景需要 8 个不同测试,仅仅为这一个属性,我们需要执行至多 216 个测试。根据这种思路,这是相当琐碎的一段代码。因此我们该怎么做呢?
测试也有代码味道
回看第一个测试用例的 8 个测试,它们都有同样的设置和运算。唯一的不同是我们写的断言。在业界这个被称为一个代码味道。事实上,根据维基百科所列的这里应该有两个代码味道:
- Duplicated code
- 重复的代码
- Excessively long identifiers
- 过长的标识符
我们可以通过将断言合并到一个测试来轻松地消除这两个代码味道:
[<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set() { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var </span>eventAssert = <span color="#0000ff">new</span> <span color="#4bacc6">PropertyChangedEventAssert</span>(person); <span color="#0000ff">var</span> errorsChangedAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ErrorsChangedEventAssert</span>(person); <span color="#0000ff">var</span> changeAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ChangeAssert</span>(person); person.FirstName = <span color="#c0504d">"Bob"</span>; <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Bob"</span>, person.FirstName, <span color="#c0504d">"FirstName setter failed"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Bob Smith"</span>, person.FullName, <span color="#c0504d">"FullName not updated with FirstName changed"</span>); <span color="#4bacc6">Assert</span>.IsTrue(person.IsChanged, <span color="#c0504d">"IsChanged flag was not set when FirstName changed"</span>); eventAssert.Expect(<span color="#c0504d">"IsChanged"</span>); eventAssert.Expect(<span color="#c0504d">"FirstName"</span>); eventAssert.Expect(<span color="#c0504d">"FullName"</span>); errorsChangedAssert.ExpectNothing(<span color="#c0504d">"Expected no ErrorsChanged events"</span>); changeAssert.AssertOnlyChangesAre(<span color="#c0504d">"FirstName", "FullName", "IsChanged"</span>); }
知道什么导致测试失败很重要,因此我们在断言里添加失败的信息提示。
单元测试和代码重用
回看那 27 个测试用例,我们可以断定设置 FirstName 为 null 或者空串应该也需求同样的测试。因此我们可以扩展成:
[<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_Empty() { Person_FirstName_Set_Invalid(<span color="#4bacc6">String</span>.Empty); } [<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_Null() { Person_FirstName_Set_Invalid(<span color="#0000ff">null</span>); } <span color="#0000ff">public void</span> Person_FirstName_Set_Invalid(<span color="#0000ff">string</span> firstName) { <span color="#0000ff">var </span>person =<span color="#0000ff"> new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> eventAssert = <span color="#0000ff">new </span><span color="#4bacc6">PropertyChangedEventAssert</span>(person); <span color="#0000ff">var</span> errorsChangedAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ErrorsChangedEventAssert</span>(person); <span color="#0000ff">var</span> changeAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ChangeAssert</span>(person); <span color="#4bacc6">Assert</span>.IsFalse(person.IsChanged, <span color="#c0504d">"Test setup failed, IsChanged is not false"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Adam"</span>, person.FirstName, <span color="#c0504d">"Test setup failed, FirstName is not Adam"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Smith"</span>, person.LastName, <span color="#c0504d">"Test setup failed, LastName is not Smith"</span>); person.FirstName = firstName; <span color="#4bacc6">Assert</span>.AreEqual(firstName , person.FirstName, <span color="#c0504d">"FirstName setter failed"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Smith"</span>, person.FullName, <span color="#c0504d">"FullName not updated with FirstName changed"</span>); <span color="#4bacc6">Assert</span>.IsTrue(person.IsChanged, <span color="#c0504d">"IsChanged flag was not set when FirstName changed"</span>); eventAssert.Expect(<span color="#c0504d">"IsChanged"</span>); eventAssert.Expect(<span color="#c0504d">"FirstName"</span>); eventAssert.Expect(<span color="#c0504d">"FullName"</span>); <span color="#4bacc6">Assert</span>.IsTrue(person.HasErrors, <span color="#c0504d">"HasErrors should have remained false"</span>); errorsChangedAssert.ExpectCountEquals(1, <span color="#c0504d">"Expected an ErrorsChanged event"</span>); changeAssert.AssertOnlyChangesAre(<span color="#c0504d">"FirstName"</span>,<span color="#c0504d"> "FullName"</span>, <span color="#c0504d">"IsChanged"</span>, <span color="#c0504d">"HasErrors"</span>); }
可以发现 Person_FirstName_Set 和 Person_FirstName_Set_Invalid 的差异很小,我们可以进一步试着通用化:
[TestMethod]
<span color="#0000ff">public void</span> Person_FirstName_Set_Valid() { Person_FirstName_Set(<span color="#c0504d">"Bob"</span>, <span color="#0000ff">false</span>); } [<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_Empty() { Person_FirstName_Set(<span color="#4bacc6">String</span>.Empty, <span color="#0000ff">true</span>); } [<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FirstName_Set_Null() { Person_FirstName_Set(<span color="#0000ff">null</span>, <span color="#0000ff">true</span>); } <span color="#0000ff">public void</span> Person_FirstName_Set(<span color="#0000ff">string</span> firstName, <span color="#0000ff">bool</span> shouldHaveErrors) { <span color="#0000ff">var</span> person = <span color="#0000ff">new</span> <span color="#4bacc6">Person</span>(<span color="#c0504d">"Adam"</span>, <span color="#c0504d">"Smith"</span>); <span color="#0000ff">var</span> eventAssert = <span color="#0000ff">new</span> <span color="#4bacc6">PropertyChangedEventAssert</span>(person); <span color="#0000ff">var</span> errorsChangedAssert = <span color="#0000ff">new</span> <span color="#4bacc6">ErrorsChangedEventAssert</span>(person); <span color="#0000ff">var</span> changeAssert =<span color="#0000ff"> new</span> <span color="#4bacc6">ChangeAssert</span>(person); <span color="#4bacc6">Assert</span>.IsFalse(person.IsChanged, <span color="#c0504d">"Test setup failed, IsChanged is not false"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Adam"</span>, person.FirstName, <span color="#c0504d">"Test setup failed, FirstName is not Adam"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Smith"</span>, person.LastName, <span color="#c0504d">"Test setup failed, LastName is not Smith"</span>); person.FirstName = firstName; <span color="#4bacc6">Assert</span>.AreEqual(firstName, person.FirstName, <span color="#c0504d">"FirstName setter failed"</span>); <span color="#4bacc6">Assert</span>.AreEqual((firstName + <span color="#c0504d">" Smith"</span>).Trim(), person.FullName, <span color="#c0504d">"FullName not updated with FirstName changed"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#0000ff">true</span>, person.IsChanged, <span color="#c0504d">"IsChanged flag was not set when FirstName changed"</span>); eventAssert.Expect(<span color="#c0504d">"IsChanged"</span>); eventAssert.Expect(<span color="#c0504d">"FirstName"</span>); eventAssert.Expect(<span color="#c0504d">"FullName"</span>); <span color="#0000ff">if</span> (shouldHaveErrors) { <span color="#4bacc6">Assert</span>.IsTrue(person.HasErrors, <span color="#c0504d">"HasErrors should have remained false"</span>); errorsChangedAssert.ExpectCountEquals(1, <span color="#c0504d">"Expected an ErrorsChanged event"</span>); changeAssert.AssertOnlyChangesAre(<span color="#c0504d">"FirstName"</span>, <span color="#c0504d">"FullName"</span>, <span color="#c0504d">"IsChanged"</span>, <span color="#c0504d">"HasErrors"</span>); } <span color="#0000ff">else </span> { errorsChangedAssert.ExpectNothing(<span color="#c0504d">"Expected no ErrorsChanged events"</span>); changeAssert.AssertOnlyChangesAre(<span color="#c0504d">"FirstName"</span>, <span color="#c0504d">"FullName"</span>, <span color="#c0504d">"IsChanged"</span>); } }
在测试代码变得令人迷惑之前,我们可以把它通用化什么程度,这里绝对有个限制。但是一个有意义的测试名称,并给每个断言配一个好的描述可以让你的测试更加容易让人理解。
控制变量
目前所有的断言都只考虑到了测试用例的输出。他们假设每个 Person 对象初始状态已知,然后从此出发进行其他操作。但是如果我们想让测试更具科学性,必须确保我们能控制变量。或者换句话说,我们需要保证,一切在掌握之中。
请看下面一组断言:
<span color="#4bacc6">Assert</span>.IsFalse(person.HasErrors, <span color="#c0504d">"Test setup failed, HasErrors is not false"</span>); <span color="#4bacc6">Assert</span>.IsFalse(person.IsChanged, <span color="#c0504d">"Test setup failed, IsChanged is not false"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Adam"</span>, person.FirstName, <span color="#c0504d">"Test setup failed, FirstName is not Adam"</span>); <span color="#4bacc6">Assert</span>.AreEqual(<span color="#c0504d">"Smith"</span>, person.LastName, <span color="#c0504d">"Test setup failed, LastName is not Smith"</span>);
由于我们不想在每个测试的开始重复这些断言,我们可以选择把他们移到一个工厂方法中,这样我们可以保证总是拿到一个干净的对象。这个同样适用于重用这些设置去测试其他属性的测试用例。
[TestMethod]
<span color="#0000ff">public void</span> Person_FirstName_Set() { <span color="#0000ff">var</span> person = GetAdamSmith(); ...
表格式的测试
之所以走到这一步,是因为“测试方法”的数量跟测试的完善程度没有关系。它们只是组织和执行测试用例一种比较方便的方式。
另一个组织大量测试用例的方法是表格驱动测试法。不能执行单个测试,但是仅用一行代码就可以增加新的测试用例。表格式测试里的表格可以来源于 XML 的文件,数据库表,写死在数组里或者只是使用同一个函数用不同的值反复调用。一些框架如 MBTest 甚至可以让你用属性给出测试用例,但是为了让例子轻便,我们还是坚持保持最低的共同部分。
[<span color="#4bacc6">TestMethod</span>] <span color="#0000ff">public void</span> Person_FullName_Tests() { Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#c0504d">"Jones"</span>, <span color="#c0504d">"Bob Jones"</span>); Person_FullName_Test(<span color="#c0504d">"Bob "</span>, <span color="#c0504d">"Jones"</span>, <span color="#c0504d">"Bob Jones"</span>); Person_FullName_Test(<span color="#c0504d">" Bob"</span>, <span color="#c0504d">"Jones"</span>, <span color="#c0504d">"Bob Jones"</span>); Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#c0504d">" Jones"</span>, <span color="#c0504d">"Bob Jones"</span>); Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#c0504d">"Jones "</span>, <span color="#c0504d">"Bob Jones"</span>); Person_FullName_Test(<span color="#0000ff">null</span>, <span color="#c0504d">"Jones"</span>, <span color="#c0504d">"Jones"</span>); Person_FullName_Test(<span color="#0000ff">string</span>.Empty, <span color="#c0504d">"Jones"</span>, <span color="#c0504d">"Jones"</span>); Person_FullName_Test(<span color="#c0504d">" "</span>, <span color="#c0504d">"Jones"</span>, <span color="#c0504d">"Jones"</span>); Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#c0504d">""</span>, <span color="#c0504d">"Bob"</span>); Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#0000ff">null</span>, <span color="#c0504d">"Bob"</span>); Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#0000ff">string</span>.Empty, <span color="#c0504d">"Bob"</span>); Person_FullName_Test(<span color="#c0504d">"Bob"</span>, <span color="#c0504d">" "</span>, <span color="#c0504d">"Bob"</span>); } <span color="#0000ff">private void</span> Person_FullName_Test(<span color="#0000ff">string</span> firstName, <span color="#0000ff">string</span> lastName, <span color="#0000ff">string</span> expectedFullName) { <span color="#0000ff">var</span> person = GetAdamSmith(); person.FirstName = firstName; person.LastName = lastName; <span color="#4bacc6">Assert</span>.AreEqual(expectedFullName, person.FullName, <span color="#0000ff">string</span>.Format(<span color="#c0504d">"Incorrect full name when first name is '{0}' and last name is '{1}'"</span> firstName ?? <span color="#c0504d">"<null>"</null></span>, lastName ?? <span color="#c0504d">"<null>"</null></span>)); }<br></br>
在运用这个技巧时,要使用带参数的错误信息,这很重要。如果不加,你会发现在定位哪些参数组合不对时,还得一步一步调试代码。
结论
在为任何变量编写单元测试时,最好尝试最大化以下几个因素:
- 有意义的单位工作量测试覆盖率
- 面对变动的代码基线时,保证可维护性
- 测试套件的性能
- 明确说明测试什么以及为什么
鉴于这些因素往往会冲突,谨慎地运用单个用例多重断言可提升上述四个方面,具体做法是: + 减少需要编写的样板代码量 + 减少因 API 更改而需要更新的样板代码量 + 减少每个断言需要执行的样板代码数量 + 将某一操作的所有断言,用文档记录在同一个地方
关于作者
Jonathan Allen 从 2006 年起一直为 InfoQ 撰写新闻报道,目前为.NET 领域的主任编辑。如果您有兴趣给 InfoQ 撰写新闻或者教育类文章,请联系他 jonathan@infoq.com .
原文地址: Writing a Comprehensive Unit Test
感谢郑柯对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论