[小北De编程手记] : Lesson 05 玩转 xUnit.Net 之 从Assert谈UT框架实践
这一篇,本文会介绍一下基本的断言概念,但重点会放在企业级单元测试的相关功能上面。下面来跟大家分享一下xUnit.Net的断言,主要涉及到以下内容:
- 关于断言的概念
- xUnit.Net常用的断言
- 关于单元测试实践的讨论
- xUnit.Net比较器:IEqualityComparer接口
- 重构Demo:浅谈UT框架实践
- 扩展实现 : 集合比较
- 异步处理
- 结合.Net平台能力:类型扩展
(一)关于断言的概念
提到断言,我想先说说概念上的东西。其实,断言不是单元测试才有的东西。先看一段断言的概念描述:
断言表示为一些布尔表达式,程序员相信在程序中的某个特定点该表达式值为真,可以在任何时候启用和禁用断言验证,因此可以在测试时启用断言而在部署时禁用断言。同样,程序投入运行后,最终用户在遇到问题时可以重新启用断言。使用断言可以创建更稳定、品质更好且不易于出错的代码。当需要在一个值为FALSE时中断当前操作的话,可以使用断言(单元测试必须使用断言)。
以上是我认为比较靠谱的一段关于断言的定义。换言之,断言其实是用来判断程序是否运行正常(比如:检查数据完整性,逻辑是否正确等等)的功能,它是可以打开和关闭的。这一点在.NET和Java等常见的开发平台下都有相关的支持。而这一功能被广泛的运用在单元测试领域,开篇先强调一下这一点,以免错误的引导了阅读本文的小伙伴。
(二)xUnit.Net常用的断言
谈到具体的断言部分,相信只要是稍微了解过单元测试的小伙伴都不会感到陌生,这里我就不再赘述了。简单的列出xUnit.Net常用的一些断言列表:
- Equal / NotEqual : 验证两个对象值相等
- Same / NotSame : 验证两个对象引用是否相同
- Contains / DoesNotContain : 验证对象是否包含(字符串\可枚举的集合)
- InRange / NotInRange : 验证对象是否在某个范围内
- Empty / NotEmpty : 验证对象是否为空
- IsType / IsNotType : 验证对象是否为某个类型
- Null / NotNull : 验证对象是否为空
- True / False : 验证表达式结果为True/False
- IsAssignableFrom : 验证类型是否可以转化为某个类型
- IsType / IsNotType : 验证对象是否是某个类型
- Throws / ThrowsAsync : 验证操作是否抛出异常
需要说明的是,每种方法xUnit.Net都提供了多种重载(包括泛型重载)。下面是官网上提供的关于xUnit.Net与其他单元测试框架对断言支持的比较。
(三)关于单元测试实践的讨论
如果只是写写Demo,前面讲到的东西已经足够了。但如果你真的想把项目的单元测试做好(搭建一个企业级的单元测试框架),我下面要讲的东西也许是更重要的。企业级的单元测试经常会被大家冠以类似这样的名头:“单元测试需要做,但是... ... 项目时间紧、代码不可测试、Mock数据之后覆盖率底下......(此处省略N多原因)”。单元测试的好处毋庸置疑,但是实践起来却屡屡受挫。我想主要的原因有以下两点:
- 没有好的管理机制的要求:项目往往是要求功能的产出。
- 内部框架支持不好。
第一点这里就不多说了,这个系列是技术主导的系列(关于管理相关的实践,我会在敏捷相关的系列中跟大家分享)。那什么是内部框架的支持呢?也就是团队内部对单元测试框架恰当的扩展和封装。请注意,这里我用了恰当两个字,这才是重点呦~~。目前,封装,架构,设计模式等等词语满天飞。我经常看见一些初出茅庐的小伙伴大谈模式和框架,动不动就要重构系统(结果,你懂的~~~)。在我看来,所有项目都会有自己的框架(即使你没有做任何的设计)。这里,需要提出一个问题:我们的设计是否是恰当的?其实,这是一个很难给出准确判断的事情,但具体的判断方式却很简单。就是看一下框架有没有达成最初的目的(对如何开始架构设计有兴趣的小伙伴可以去读读《恰如其分的软件架构》,也后有机会会跟大家分享读后感... ...)。比如,我们设计框架是为了提高生产率。那么我们就应当对复杂的技术细节进行封装,为使用框架的人提供简单、易用、学习成本低的接口。如果设计框架是为了避免安全性问题,那么最后我们就需要考量整个框架在实际的使用过程中的对安全性的提升程度... ... 很遗憾,我们现在遇到的很多框架是为了设计框架而设计的框架。结果和我们的预期南辕北辙。
吐槽到此结束,本文也没有打算来讲一个单元测试框架如何设计。现在,我们来看看就单元测试的框架设计而言。我们应当做那些设计和恰当的封装呢?当然,没有银弹能兼顾所有的问题。下面要提到的主要是结合本文提到的知识点做一些相关扩展。
(四)xUnit.Net比较器:IEqualityComparer接口
仔细观察一下Assert所提供的方法定义,你会发现很多需要比较的操作都提供了一个接收IEqualityComparer对象的实现。这一小节,我们就来看看如何使用这个功能。顾名思义IEqualityComparer接口用于两个对象的比较。当我们需要自定义一些比较逻辑时,这个功能应当是首选。先看一下IEqualityComparer的定义:
1 namespace System.Collections.Generic 2 { 3 public interface IEqualityComparer<in T> 4 { 5 bool Equals(T x, T y); 6 int GetHashCode(T obj); 7 } 8 }
可以看到,该接口接收一个用于比较的类型,并且定义了两个方法:
- Equals : 提供自定义的比较逻辑。
- GetHashCode : 提供对象HasCode生成逻辑。
在xUnit.Net执行断言判断的时,如果使用了自定义的比较逻辑,就会使用Equals判断是否相等,用GetHashCode来获取对象的标识(这个不是每次都会用到,有兴趣可以看看xUNit.Net的源码)。下面的Code是来自xUnit.Net官网的列子:
1 class DateComparer : IEqualityComparer<DateTime> 2 { 3 public bool Equals(DateTime x, DateTime y) 4 { 5 return x.Date == y.Date; 6 } 7 8 public int GetHashCode(DateTime obj) 9 { 10 return obj.GetHashCode(); 11 } 12 } 13 14 [Fact(DisplayName = "Assert.DateComparer.Demo01")] 15 public void Assert_DateComparer_Demo01() 16 { 17 var firstTime = DateTime.Now.Date; 18 var later = firstTime.AddMinutes(90); 19 20 Assert.NotEqual(firstTime, later); 21 Assert.Equal(firstTime, later, new DateComparer()); 22 }
这里只是一个Demo,更多的使用场景是我们可以用这样的方式为自定义的类提供比较逻辑。上面的代码简单的实现了针对日期的比较逻辑,步骤如下:
- 创建DateComparer类,实现IEqualityComparer接口定义的方法。
- 在Assert.Equal 中使用对应的方法并传入相应的比较类(其中定义了比较逻辑)。
(五)重构Demo:浅谈UT框架实践
说到这里,如果你只是想了解一下xUnit.Net的使用。那么,你可以跳过这一部分,从下一个小节开始看了。我准备从设计比较函数的角度来谈谈单元测试框架的设计(也同样适用于很多的开发框架设计)。
在实际的应用中,你可以使用Demo中的操作(很多公司也确实是这么做的)。随着项目的演进(一段时间以后)你会发现代码中到处散落着实现了IEqualityComparer的对象,这样的维护成本可想而知。建议的做法是对系统中IEqualityComparer类型统一封装,同时使用单件模式他们的构造进行控制。
首先,我们来使用单件模式重构刚刚的DateComparer类,如Code标黑的部分所示,这里屏蔽了DateComparer的构造函数,并实现了单件模式的调用:
1 class DateComparer : IEqualityComparer<DateTime> 2 { 3 private DateComparer() { } 4 5 private static DateComparer _instance; 6 public static DateComparer Instance 7 { 8 get 9 { 10 if (_instance == null) 11 { 12 _instance = new DateComparer(); 13 } 14 return _instance; 15 } 16 } 17 18 public bool Equals(DateTime x, DateTime y) 19 { 20 return x.Date == y.Date; 21 } 22 23 public int GetHashCode(DateTime obj) 24 { 25 return obj.GetHashCode(); 26 } 27 }
其次,创建一个工厂类统一的控制所有单件比较类的创建逻辑:
1 class SingletonFactory 2 { 3 public static DateComparer CreateDateComparer() 4 { 5 return DateComparer.Instance; 6 } 7 //Other Comparer ... ... 8 }
现在,实际的测试代码会变成下面的样子:
1 public class SingletonFactory_Demo 2 { 3 [Fact(DisplayName = "Assert.Singleton.DateComparer.Demo01")] 4 public void Assert_Singleton_DateComparer_Demo01() 5 { 6 var firstTime = DateTime.Now.Date; 7 var later = firstTime.AddMinutes(90); 8 9 Assert.NotEqual(firstTime, later); 10 Assert.Equal(firstTime, later, SingletonFactory.CreateDateComparer()); 11 } 12 }
其实,调用本身的代码量并没有减少(反而多了),那么我们为什么要这样实现呢?回到之前关于单元测试实践的讨论中所提到的。对于单元测试框架的设计我们的目的是什么? 这里我列出来几个:
- 提高开发效率(降低框架使用者的学习成本)。
- 易于维护和管理。
- 降低Test Case运行的时间成本
关于使用者的成本,之前的做法需要使用者明白如何构建IEqualityComparer接口,并定义比较方法。而后一种方法,IEqualityComparer的实现是由框架开发人员或者熟悉xUnit.Net的资深程序员来做的。也就是说,降低了框架使用者的学习成本。也许有人会反驳,这个逻辑很简单不需要封装。如果我们需要比较的对象描述是两份很复杂的财务报表的对象呢?这样的比较逻辑是不是需要具有相关的业务知识以及对IEqualityComparer接口(虽然接口很简单)的了解呢?这个样封装使得复杂的逻辑被分离开来。
关于可维护性要从两个方面来说。第一,实际的项目中所有针对IEqualityComparer的实现都是统一维护的,无论是创建者还是后来的维护人员都能轻易的找到系统中已有的实现。第二,由于做了一些拆分。可以让更熟悉比较逻辑(复杂对象)的专家来完成框架的代码。而开发人员可以专心的编写测试逻辑,而不是关注对象比较。
关于降低Test Case运行的时间成本。试想一下,如果比较对象的是很耗时或者资源开销的操作(例如需要调用外部的服务....),使用单例模式是不是就大大减低了这方面的成本。
与此同时,我们会提出一个架构层面的约定:“不要直接使用实现了IEqualityComparer的比较对象,如果当前的测试框架没有提供你想要的功能,请按框架的实现方式提交你的Code”。 相信大家很容易理解这个约定的原因,但是如果它是在本文的一开始就提出的,你也许会觉得很不可理解吧~~~~ 那么,什么是框架设计?
我的理解: 框架设计 = 恰当的约束 + Code;
Code就是框架代码的具体实现,而约束恰恰是更加重要的一环。如果一个框架没有靠谱的约束列表,最后的实现和架构师起初的设想一定是南辕北辙的。
当然,用Assert来谈UT的框架设计,貌似有些管中窥豹的味道。这里只是想用例子来分享一下本人对框架设计的一些认识。又扯远了,还是回到xUnit.Net的功能上面吧。
(六)扩展实现 : 集合比较Demo
言归正传,前面我们已经向大家展示了如何在类型级别扩展断言的比较能力(即IEqualityComparer<T>接口),这里我们来实现一个逻辑稍复杂也更加实用一些的比较类。先看以一下场景 : 我们要比较两个集合的内容是否一致(与数据数序无关)。如果理解了之前的例子,实现这个功能应该很容易:
首先,定义实现了IEqualityComparer接口的比较类,也之前不同的是:这里泛型的类型指定为可枚举的集合(IEnumerable<T>)。比较方法中,排序对比结果。
1 class CollectionEquivalenceComparer<T> : IEqualityComparer<IEnumerable<T>> 2 where T : IEquatable<T> 3 { 4 public bool Equals(IEnumerable<T> x, IEnumerable<T> y) 5 { 6 List<T> leftList = new List<T>(x); 7 List<T> rightList = new List<T>(y); 8 leftList.Sort(); 9 rightList.Sort(); 10 11 IEnumerator<T> enumeratorX = leftList.GetEnumerator(); 12 IEnumerator<T> enumeratorY = rightList.GetEnumerator(); 13 14 while (true) 15 { 16 bool hasNextX = enumeratorX.MoveNext(); 17 bool hasNextY = enumeratorY.MoveNext(); 18 19 if (!hasNextX || !hasNextY) 20 return (hasNextX == hasNextY); 21 22 if (!enumeratorX.Current.Equals(enumeratorY.Current)) 23 return false; 24 } 25 } 26 27 public int GetHashCode(IEnumerable<T> obj) 28 { 29 throw new NotImplementedException(); 30 } 31 }
然后,我们看一下Test Case:
1 [Fact] 2 public void DuplicatedItemInOneListOnly() 3 { 4 List<int> left = new List<int>(new int[] { 4, 16, 12, 27, 4 }); 5 List<int> right = new List<int>(new int[] { 4, 12, 16, 27 }); 6 7 Assert.NotEqual(left, right, new CollectionEquivalenceComparer<int>()); 8 } 9 10 [Fact] 11 public void DuplicatedItemInBothLists() 12 { 13 List<int> left = new List<int>(new int[] { 4, 16, 12, 27, 4 }); 14 List<int> right = new List<int>(new int[] { 4, 12, 16, 4, 27 }); 15 16 Assert.Equal(left, right, new CollectionEquivalenceComparer<int>()); 17 }
例子很简单,但却是很多单元测试会经常使用的功能。so ... ... 列出来给大家,顺便巩固一下IEqualityComparer的使用。
(七)异步处理
实际的单元测试中,一些测试方法需要通过异步的方式调用。xUnit.Net很好的结合了C#所提供的异步操作能力。1.9之前的xUnit.Net使用了Task的方式来实现异步操作,这里就不介绍了(已是过去时~~~)。1.9之后的xUnit.Net版本结合C#中 async / await 提供的能力。非常简单的实现了针对异步方法的测试需求,先看一个Demo:
1 public class Assert_Async 2 { 3 [Fact] 4 public async void CodeThrowsAsync() 5 { 6 Func<Task> testCode = () => Task.Factory.StartNew(ThrowingMethod); 7 8 var ex = await Assert.ThrowsAsync<NotImplementedException>(testCode); 9 10 Assert.IsType<NotImplementedException>(ex); 11 } 12 13 [Fact] 14 public async void RecordAsync() 15 { 16 Func<Task> testCode = () => Task.Factory.StartNew(ThrowingMethod); 17 18 var ex = await Record.ExceptionAsync(testCode); 19 20 Assert.IsType<NotImplementedException>(ex); 21 } 22 23 void ThrowingMethod() 24 { 25 throw new NotImplementedException(); 26 } 27 }
如上面的Code所示,使用xUnit.Net编写异步处理相关的Unit Test,一般有以下几个步骤:
- Test Case 方法标记为 : async
- 定义待测试的方法
- 使用Assert.ThrowsAsync或者Record.ExceptionAsync来执行线程操作
- 判断结果
(八)结合.Net平台能力:类型扩展
xUnit.Net的一个特点之一,就是充分的发挥了C#语言和.Net平台本身的能力。从异步的处理就可见一斑,这一部分的最后我打算跟分享一下如何使用静态扩展方法来增强类型系统本身对断言的支持。看一下官网提供的Demo:
1 namespace Xunit.Extensions.AssertExtensions 2 { 3 /// <summary> 4 /// Extensions which provide assertions to classes derived from <see cref="Boolean"/>. 5 /// </summary> 6 public static class BooleanAssertionExtensions 7 { 8 /// <summary> 9 /// Verifies that the condition is false. 10 /// </summary> 11 /// <param name="condition">The condition to be tested</param> 12 /// <exception cref="FalseException">Thrown if the condition is not false</exception> 13 public static void ShouldBeFalse(this bool condition) 14 { 15 Assert.False(condition); 16 } 17 18 /// <summary> 19 /// Verifies that the condition is false. 20 /// </summary> 21 /// <param name="condition">The condition to be tested</param> 22 /// <param name="userMessage">The message to show when the condition is not false</param> 23 /// <exception cref="FalseException">Thrown if the condition is not false</exception> 24 public static void ShouldBeFalse(this bool condition, 25 string userMessage) 26 { 27 Assert.False(condition, userMessage); 28 } 29 30 /// <summary> 31 /// Verifies that an expression is true. 32 /// </summary> 33 /// <param name="condition">The condition to be inspected</param> 34 /// <exception cref="TrueException">Thrown when the condition is false</exception> 35 public static void ShouldBeTrue(this bool condition) 36 { 37 Assert.True(condition); 38 } 39 40 /// <summary> 41 /// Verifies that an expression is true. 42 /// </summary> 43 /// <param name="condition">The condition to be inspected</param> 44 /// <param name="userMessage">The message to be shown when the condition is false</param> 45 /// <exception cref="TrueException">Thrown when the condition is false</exception> 46 public static void ShouldBeTrue(this bool condition, 47 string userMessage) 48 { 49 Assert.True(condition, userMessage); 50 } 51 } 52 }
这里利用了C#的静态扩展方法对类型bool 进行了扩展(当然,你也可以扩展任何一个已有的类型),内部使用 Assert 做了一些常规的断言判断。现在Unit Test Case的调用代码就变成了如下所示:
1 [Fact] 2 public void ShouldBeTrue() 3 { 4 Boolean val = true; 5 6 val.ShouldBeTrue(); 7 } 8 9 [Fact] 10 public void ShouldBeFalse() 11 { 12 Boolean val = false; 13 14 val.ShouldBeFalse(); 15 } 16 17 [Fact] 18 public void ShouldBeTrueWithMessage() 19 { 20 Boolean val = false; 21 22 Exception exception = Record.Exception(() => val.ShouldBeTrue("should be true")); 23 24 Assert.StartsWith("should be true", exception.Message); 25 }
当然,对于这种用法本人还是持保留意见的。毕竟只是Assert的简单封装,更像是语法糖。但貌似很多团队有这样的开发风格,仁者见仁啦~~~。
总结:
本文主要介绍了xUnit.Net的断言相关的使用,扩展。也简单的谈到了UT(Unit Test)框架的设计。这一篇文章吐了很多槽,回顾一下:
- 关于断言的概念
- xUnit.Net常用的断言
- 关于单元测试实践的讨论
- xUnit.Net比较器:IEqualityComparer接口
- 重构Demo:浅谈UT框架实践
- 扩展实现 : 集合比较
- 异步处理
- 结合.Net平台能力:类型扩展
小北De系列文章:
《[小北De编程手记] : Selenium For C# 教程》
《[小北De编程手记]:C# 进化史》(未完成)
《[小北De编程手记]:玩转 xUnit.Net》(未完成)
Demo地址:https://github.com/DemoCnblogs/xUnit.Net