1】开篇介绍
最近一段时间结束了一个Sprint,在这次的开发当中有些东西觉得还不错有总结分享的价值,所以整理成本文;
重构已是老生常谈的话题,我们或多或少对它有所了解但是对它的深刻理解恐怕需要一段实践过后才能体会到;提到重构就不得不提为它保驾护航的大功臣单元测试,重构能有今天的风光影响力完全少不了单元测试的功劳;最近一段时间写单元测试用例的时间远超过我写逻辑代码的时间和多的多的代码量,这是为什么?我一开始很难给自己一个理由去做好这件事,心态上还是转变不过来,可是每当我心浮气躁的时候它总能给我点惊喜,让我继续下去,天生具有好奇心的程序员怎么会就此结束呢,只有到达了一扇门之后我们回过头来看一下走的路才能真正的明白这是条对的路还是错的路;
单元测试简单写起来没有什么太大问题,但是我们不仅为了达到代码的100%覆盖还要到达到逻辑的100%覆盖,代码的覆盖不代表逻辑的覆盖;一个简单的逻辑判断虽然只有一行代码,但是里面可能会有正反向很多种逻辑在里面;比如:Order.ToString()简单的代码,想要覆盖很简单,只要对象不为空都能正确的覆盖到,但是如果我们没有测试到它为NULL的情况下的边界逻辑,这个时候我们就会漏掉这种可能会导致BUG的逻辑路径;所以我们会尽可能的多去写用例来达到最终的理想效果;
(总之把单元测试的所有精力集中在可能会出问题的地方,也是自己最担心的地方,这个地方通常是逻辑比较复杂的地方;)
2】单元测试、测试用例代码重复问题(大量使用重复的Mock对象及测试数据)
单元测试代码中最常见的代码就是Mock或者Fake接口逻辑,那么在一个具有上百个用例覆盖的代码中会同时使用到一组相关的Mock接口对象,这无形中增加了我们编写单元测试的效率给后期的维护测试用例带来了很大的隐患及工作量;
单元测试代码的组成都是按照用例来划分,一个用例可以用来包括一个单一入口的所有逻辑也可以是一个判断分支的部分逻辑;为了构造一个能完美覆盖的代码步骤,我们需要构建测试数据、Mock接口,划分执行顺序等等,那么一旦被测试代码发生一点点的变化都会很大程度上影响测试代码,毕竟测试代码都是步步依赖的;
那么我们应该最大程度的限制由于被测试代码的变动而引起的测试代码的变动,这个时候我们应该将重构应用到测试代码中;
2.1】单元测试的继承体系(利用超类来减少Mock对象的使用)
将多个相关的测试用例代码通过超类的方式关联起来统一管理将大大减少重复代码的构建;就跟我们重构普通代码一样,将多个类之间共享的逻辑代码或者对象提取出来放到基类中;这当然也同样适用于测试代码,只不过需要控制一些更测试相关的逻辑;
其实大部分重复的代码就是Mock接口的过程,我们需要将它的Mock过程精简化,但是又不能太过于精简,一切精简的过程都是需要牺牲可观察性;我们需要适当的平衡提取出来的对象个数,将它们放入基类中,然后在Mock的时候能通过一个简单的方法就能获取到一个Mock过后的对象;
下面我们来看一下提取公共部分到基类的一个 简单过程,当然对于大项目而言不一定具有说服力,就当抛砖引玉吧;
2.1.1】公用的Mock对象
首要的任务就是将公共的Mock接口提取出来,因为这一类接口是肯定会在各个用例中共享的,提取过程过主要分为两个重构过程;
第一:将用例中的公用接口放到类的声明中,供所有用例使用;
第二:如果需要将公用接口提供给其他的单元测试使用,就需要提取出相关的测试基类;
我们先来看一下第一个过程,看一下测试示例代码:
namespace UnitTestRefactoring
{
public class OrderService
{
private IServiceConnection ServiceConnection;
private IServiceReader ServiceReader;
private IServiceWriter ServiceWrite;
public OrderService(IServiceConnection connection, IServiceReader reader, IServiceWriter writer)
{
this.ServiceConnection = connection;
this.ServiceReader = reader;
this.ServiceWrite = writer;
}
public bool GetOrders(string orderId)
{
if (string.IsNullOrWhiteSpace(orderId))
return false;
return true;
}
}
}
|
这个类表示远程Order服务,只有一个方法GetOrders,该方法可以根据OrderId来查询Order信息,为了简单起见,如果返回true说明服务调用成功,如果返回false表示调用失败;其中构造函数包含了三个接口,分别用来表示不同用途的接口抽象
IServiceConnection表示对远程服务链接的抽象,IServiceReader表示对不同服务接口读取的抽象,IServiceWriter表示对不同服务接口写入的抽象;这么做可以最大化的分解耦合;
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using UnitTestRefactoring;
namespace UnitTestRefactoring.UnitTests
{
[TestClass]
public class OrderService_UnitTests
{
[TestMethod]
public void OrderService_GetOrders_NormalFlows()
{
IServiceConnection mockServiceConnection = Substitute.For();
IServiceReader mockServiceReader = Substitute.For();
IServiceWriter mockServiceWriter = Substitute.For();
OrderService testOrderService = new OrderService
(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testOrderService.GetOrders("10293884");
Assert.AreEqual(true, testResult);
}
[TestMethod]
public void OrderService_GetOrders_OrderIdIsNull()
{
IServiceConnection mockServiceConnection = Substitute.For();
IServiceReader mockServiceReader = Substitute.For();
IServiceWriter mockServiceWriter = Substitute.For();
OrderService testOrderService = new OrderService
(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testOrderService.GetOrders(string.Empty);
Assert.AreEqual(false, testResult);
}
}
}
|
这个单元测试类是专门用来测试刚才那个OrderService的,里面包括两个GetOrders方法的测试用例;可以一目了然的看见,这两个测试用例代码中都包含了对测试类的构造函数的参数接口Mock代码;
图1:
像这种简单的情况下,我们只需要将公共的部分拿出来放到测试的类中声明,就可以公用这块对象;
图2:
这样可以解决内部重复问题,但是这里需要小心的地方是,当我们在不同的用例之间共享部分Mock逻辑的时候可能会出现问题;比如我们在OrderService_GetOrders_NormalFlows用例中,对IServiceConnection接口进行了部分行为的Mock但是当执行到OrderService_GetOrders_OrderIdIsNull用例时可能是用的我们上一次的Mock逻辑;所以这里需要注意一下,当然如果设计合理的话是不太可能会出现这种问题的;单一职责原则只要满足我们的接口是不会包含其他的逻辑在里面,也不会出现在不同的用例之间共存相同的接口逻辑;同时也满足接口隔离原则,就会更加对单元测试有利;
我们接着看一下第二个过程,看一下测试示例代码:
namespace UnitTestRefactoring
{
public class ProductService
{
private IServiceConnection ServiceConnection;
private IServiceReader ServiceReader;
private IServiceWriter ServiceWrite;
public ProductService(IServiceConnection connection, IServiceReader reader, IServiceWriter writer)
{
this.ServiceConnection = connection;
this.ServiceReader = reader;
this.ServiceWrite = writer;
}
public bool GetProduct(string productId)
{
if (string.IsNullOrWhiteSpace(productId))
return false;
return true;
}
}
}
|
这个是表示Product服务,构造函数中同样和之前的OrderService一样的参数列表,然后就是一个简单的GetProduct方法;
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
using UnitTestRefactoring;
namespace UnitTestRefactoring.UnitTests
{
[TestClass]
public class ProductService_UnitTests
{
IServiceConnection mockServiceConnection = Substitute.For();
IServiceReader mockServiceReader = Substitute.For();
IServiceWriter mockServiceWriter = Substitute.For();
[TestMethod]
public void ProductService_GetProduct_NormalFlows()
{
ProductService testProductService = new ProductService
(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testProductService.GetProduct("5475684684");
Assert.AreEqual(true, testResult);
}
[TestMethod]
public void ProductService_GetProduct_ProductIsNull()
{
ProductService testProductService = new ProductService
(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testProductService.GetProduct(string.Empty);
Assert.AreEqual(false, testResult);
}
}
}
|
这是单元测试类,没有什么特别的,跟之前的OrderService一样的逻辑;是不是发现两个测试类都在公用一组相关的接口,这里就需要我们将他们提取出来放入基类中;
using NSubstitute;
namespace UnitTestRefactoring.UnitTests
{
public abstract class ServiceBaseUnitTestClass
{
protected IServiceConnection mockServiceConnection = Substitute.For();
protected IServiceReader mockServiceReader = Substitute.For();
protected IServiceWriter mockServiceWriter = Substitute.For();
}
}
|
提取出来的测试基类;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace UnitTestRefactoring.UnitTests
{
[TestClass]
public class ProductService_UnitTests : ServiceBaseUnitTestClass
{
[TestMethod]
public void ProductService_GetProduct_NormalFlows()
{
ProductService testProductService = new ProductService
(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testProductService.GetProduct("5475684684");
Assert.AreEqual(true, testResult);
}
[TestMethod]
public void ProductService_GetProduct_ProductIsNull()
{
ProductService testProductService = new ProductService
(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testProductService.GetProduct(string.Empty);
Assert.AreEqual(false, testResult);
}
}
}
|
ProductService_UnitTests类;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace UnitTestRefactoring.UnitTests
{
[TestClass]
public class OrderService_UnitTests : ServiceBaseUnitTestClass
{
[TestMethod]
public void OrderService_GetOrders_NormalFlows()
{
OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testOrderService.GetOrders("10293884");
Assert.AreEqual(true, testResult);
}
[TestMethod]
public void OrderService_GetOrders_OrderIdIsNull()
{
OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testOrderService.GetOrders(string.Empty);
Assert.AreEqual(false, testResult);
}
}
}
|
OrderService_UnitTests 类;
提取出来的抽象基类能在后面的单元测试重构中帮很大忙,也是为了后面的面向特定领域的单元测试框架做要基础工作;由于不同的单元测试类具有不同的基类,这里需要我们自己的分析抽象,比如这里跟Service相关的,可能还有跟Order处理流程相关的,相同的一组接口也只能出现在相关的测试类中;
2.1.2】公用的Mock行为
前面2.1.1】小结,我们讲了Mock接口对象的重构,这一节我们将来分析一下关于Mock对象行为的重构;在上面的IServiceConnection中我们加入了一个Open方法,用来打开远程链接;
namespace UnitTestRefactoring
{
public interface IServiceConnection
{
bool Open();
}
}
|
如果返回true表示远程链接成功建立并且已经成功打开,如果返回false表示链接失败;那么在每一个用例代码中,只要使用到了IServiceConnection接口都会需要Mock接口的Open方法;
[TestMethod]
public void OrderService_GetOrders_NormalFlows()
{
mockServiceConnection.Open().Returns(true);
mockServiceConnection.Close().Returns(true);
OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testOrderService.GetOrders("10293884");
Assert.AreEqual(true, testResult);
}
[TestMethod]
public void OrderService_GetOrders_OrderIdIsNull()
{
mockServiceConnection.Open().Returns(true);
mockServiceConnection.Close().Returns(false);
OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
bool testResult = testOrderService.GetOrders(string.Empty);
Assert.AreEqual(false, testResult);
}
|
类似这样的代码会很多,如果这个时候我们需要每次都在用例中对三个接口都进行类似的重复代码也算是一种地效率的重复劳动,并且在后面的改动中会很费事;所以这个时候抽象出来的基类就派上用场了,我们可以将构建接口的逻辑代码放入基类中进行统一构造;
public abstract class ServiceBaseUnitTestClass
{
protected IServiceConnection mockServiceConnection = Substitute.For();
protected IServiceReader mockServiceReader = Substitute.For();
protected IServiceWriter mockServiceWriter = Substitute.For();
protected void InitMockServiceConnection()
{
this.mockServiceConnection.Open().Returns(true);
this.mockServiceConnection.Close().Returns(true);
}
}
this.InitMockServiceConnection();
OrderService testOrderService = new OrderService(mockServiceConnection, mockServiceReader, mockServiceWriter);
|
这样在需要修改接口的时候很容易找到,可能这里两三个用例,而且用例代码也很简单所以看起来没有太多的必要,但是实际情况没有这么简单;
2.1.3】公用的Mock数据
说到Mock数据,其实需要解释一下,准确点讲是Mock时需要用到的测试数据,它是碎片化的简单的测试数据;它也同样存在着和2.1.2】小结的修改问题,实践告诉我单元测试代码在整个开发周期中最易被修改,当我们简单的修改一个逻辑之后就需要面临着大面积的单元测试代码修改而测试数据修改占比重最大;
因为测试数据相对没有灵活性,但是测试数据的结构易发生由需求带来的变化;比如实体的属性类型,在我们编写实体测试数据的时候我们用的是String,一段时间过后,实体发生变化很正常;领域模型在开发周期中被修改的次数那是无法估计,因为我们的项目中是需要迭代重构的,我们需要重构来为我们的项目保证最高的质量;
所以单元测试修改的次数和重构的次数应该是成1:0的这样的比例,修改的范围那就不是1:10了,有时候甚至是几何的倍数;
OrderService中的AddOrder方法:
public bool AddOrder(Order order)
{
if (string.IsNullOrWhiteSpace(order.OrderId))
return false;
return true;
}
|
OrderService_AddOrder测试代码:
[TestMethod]
public void OrderService_AddOrder_NormalFlows()
{
this.InitMockServiceConnection();
OrderService testOrderService = new OrderService(this.mockServiceConnection,
this.mockServiceReader, this.mockServiceWriter);
Order testOrder = new Order() { OrderId = "123456", SubmitDT = DateTime.Now };
bool testResult = testOrderService.AddOrder(testOrder);
Assert.AreEqual(true, testResult);
}
[TestMethod]
public void OrderService_AddOrder_OrderIdIsNull()
{
this.InitMockServiceConnection();
OrderService testOrderService = new OrderService(this.mockServiceConnection,
this.mockServiceReader, this.mockServiceWriter);
Order testOrder = new Order() { OrderId = string.Empty, SubmitDT = DateTime.Now };
bool testResult = testOrderService.AddOrder(testOrder);
Assert.AreEqual(false, testResult);
}
|
这是两个用例,用来对AddOrder方法进行测试,里面都包含了一条Order
testOrder = new Order() 这样的测试数据的构造;Order实体是一个比较简单的对象,属性也就只有两个,但是真实环境中不会这么简单,会有几十个字段都需要进行测试验证,再加上N多个用例,会使相同的代码变的很多;
那么我们同样需要将这部分的代码提取出来放到基类中去,适当的留有空间让用例中修改的特殊的字段;
完整的实体构造:
Order testOrder = this.InitNormalOrder();
|
测试OrderId为空的逻辑,需要手动设置为String.Empty:
Order testOrder = this.InitNormalOrder();
testOrder.OrderId = string.Empty; |
这样慢慢的就会形成抗变化的测试代码结构,尽管一开始很别扭,将一些直观的对象提取出来放入一眼看不见的地方是有点不太舒服,但是长远看来值得这么做;
3】LINQ表达式的重构写法(将必要的LINQ写成普通的Function穿插在LINQ表达式中)
在使用LINQ语法编写代码的时候,现在发现最大的问题就是单元测试不太方便,LINQ写起来很方便,确实是个很不错的编程思想,在面对集合类型的操作时确实是无法形容的优雅,但是面对单元测试的问题需要解决才行,所以需要我们平衡一下在什么情况
需要将LINQ表达式替换成普通的Function来支持;
LINQ在面对集合类型的时候,能发挥很大的作用;不仅在Linq to Object中,在其他的Linq
to Provider中都能在LINQ中找到了合适的使用之地;比如在对远程Service进行LINQ设计的时候,我们都是按照这样的方式进行编写,但是就怕LINQ中带有逻辑判断的表达式,这个时候就会在单元测试中总是无法覆盖到的情况出现,所以就需要将它提取出来使用普通的函数进行替代;
我们来继续看一下如果使用提取出来的函数解决链式的判断,还是使用上面的OrderService为例:
public Order SelectByOrderId(string orderId)
{
List orders = new List()
{
new Order(){ OrderId="123", SubmitDT=DateTime.Now.AddDays(1)},
new Order(){ OrderId="234"}
};
var list = orders.Where(order => order.OrderId == orderId && order.SubmitDT > DateTime.Now);
if (list.Count() > 0)
return list.ToList()[0];
return null;
}
|
这是一个根据OrderId获取Order实例的方法,纯粹为了演示;首先构造了一个测试集合,然后使用了Where扩展方法来选择集合中满足条件的Order;我们的重点是Where中的条件,条件的第一个表达式很简单而第二个表达式是SubmitDT必须大于当前的日期,还会有很多类似这样的判断,这样测试起来很困难,而且很难维护,所以我们有必要将他们提取出来;
public Order SelectByOrderId(string orderId)
{
List orders = new List()
{
new Order(){ OrderId="123", SubmitDT=DateTime.Now.AddDays(1)},
new Order(){ OrderId="234"}
};
var list = orders.Where(order => IfOrderSubmitAndOrderId(order, orderId));
if (list.Count() > 0)
return list.ToList()[0];
return null;
}
private bool IfOrderSubmitDt(Order order)
{
return order.SubmitDT > DateTime.Now;
}
private bool IfOrderSubmitAndOrderId(Order order, string orderId)
{
return order.OrderId == orderId && this.IfOrderSubmitDt(order);
}
|
其实这很像企业架构模式中的规约模式,将规则对象化后就能随便的控制他们,当然这里是提取出方法,如果是大型企业级项目对这些易变化的点是需要抽取出来的;
总之遇到这样的情况就使用简单的提取方法的方式将复杂的逻辑提取出来,这也是《重构》中的重构策略的首要的模式;
4.面向特定领域的单元测试框架(一切原则即是领域驱动)
领域驱动设计已经不是什么新鲜的话题了,它已经被我们或多或少的使用过,它强调一切从领域出发;那么特定领域单元测试框架是一个什么样的框架呢,需要的价值在哪里;其实从特定领域开发框架,特定领域架构我们能简单的体会到一丝意思,面向特定领域单元测试框架是在单元测试框架的基础之上进行二次领域相关的封装;比如:如何很好的将领域规则独立起来,如果在单元测试中使用这些独立起来的领域规则;
其实在软件开发的任何一个角落都能找到领域驱动的影子,这也是为什么领域驱动会得到我们认可的重要因素;如果一切都围绕着领域模型来的话,那么任何一个概念都不会牵强的,我们只有关注领域本身才能使软件真的很有价值,而不是一堆代码;
下面我们来简单的看一下 面向特定领域测试框架 的两个基本功能:
4.1.分散测试逻辑、日志记录(让测试逻辑可以重组,记录形式为领域模型)
测试代码执行到最后是需要对其执行的结果进行断言的,如:Assert.IsTrue(testResult.SubmitDT
> DateTime.Now);像这样的一段代码我们可以适当的包装Assert.IsTrue方法,让他在验证这段逻辑的时候能识别出领域概念,比如:“Order的提交时间大于今天的时间”,我们可以从两方面入手,一个是领域的抽象,一个是规则的分解;
如果这里的验证不通过,我们实时的记录领域的概念到日志系统,而不是报告那里代码出问题,这样就算不是自己写的代码都能一目了然;
4.2.测试用例的数据重用(为自动化测试准备固定数据,建立Assert的比较测试数据)
同样比较重要的领域概念就是领域数据,领域数据也是单元测试中用例数据;为了能让测试进行自动化测试,我们需要维护一组相对固定的测试数据来供测试程序运行;其实如果想最大化建立领域测试框架有必要开发一套专门的领域测试工具,它能够实时的读取真实数据进行Assert,也就更加的接近自动化测试;
但是单元测试也不需要对真实数据进行验证,真实数据一般是集成测试的时候使用的,如果能用真实数据进行逻辑测试还是很有保障的;
|