[小北De编程手记] : Lesson 06 玩转 xUnit.Net 之 定义自己的FactAttribute
xUnit.Net本身提供了标记测试方法的标签Fact和Theory。在前面的文章《Lesson 02 玩转 xUnit.Net 之 基本UnitTest & 数据驱动》中,也对它们做了详细的介绍。这一篇,来分享一个高级点的主题:如何扩展标签?还是老规矩,看一下议题:
- 概述
- 让xUnit.Net识别你的测试Attribute
- 定义运行策略:XunitTestCase
- 与Runner交流:消息总线 - IMessageBus
- 总结
这一篇有一些不大容易理解的东东。因此,我是默认读者已经读过之前的五篇文章(或者已经充分的了解xUnit.Net的基本知识)。另外,最好熟悉面向对象的方法,一些接口编程的实践。(之前文章的可以在这里找到《[小北De编程手记]:玩转 xUnit.Net》)。当然,要是仅仅达到使用xUnit.Net做做UT,完成基本工作的级别。我想之前的文章所描述的知识点已经足够了。要是还想进一步了解xUnit.Net。那么,这一篇所讲的内容也许是你进阶的必经之路。也是本人觉得最好的一个开端... ...
(一)概述
在单元测试的实践中,Fact和Theory已经能满足我们许多的要求。但是对于一些特殊的情况,例如:需要多次运行一个方法的测试用例(10秒钟内支付接口只能做3次),或者需要开启多个线程来运行测试用例。这些需求我们当然可以通过编码来完成。但如果可以用属性标记的方式来简单的实现这样的功能。就会大大降低使用者的编程复杂度,这样的能力也是在设计一个单元测试框架的时候需要考虑的。xUnit.Net为我们提供的优雅的接口,方便我们对框架本身进行扩展。这一篇,我们就来介绍如何实现自定义的测试用例运行标签(类似Fact和Theory)。这一篇的内容略微有点复杂,为了让大家能快速的了解我要实现什么样的功能,先来看一下最终的Test Case:
1 public class RetryFactSamples 2 { 3 public class CounterFixture 4 { 5 public int RunCount; 6 } 7 8 public class RetryFactSample : IClassFixture<CounterFixture> 9 { 10 private readonly CounterFixture counter; 11 12 public RetryFactSample(CounterFixture counter) 13 { 14 this.counter = counter; 15 counter.RunCount++; 16 } 17 18 [RetryFact(MaxRetries = 5)] 19 public void IWillPassTheSecondTime() 20 { 21 Assert.Equal(2, counter.RunCount); 22 } 23 } 24 }
可以看到,用来标记测试用了的属性标签不再是xUnit.Net提供的Fact或者Theory了,取而代之的是自定义的RetryFact标签。顾名思义,实际的测试过程中标签会按照MaxRetries所设置的次数来重复执行被标记的测试用例。自定义运行标签主要有下面几个步骤:
- 创建标签自定义标签
- 创建自定义的TestCaseDiscoverer
- 创建自定义的XunitTestCase子类
- 重写消息总线的传输逻辑
该功能也是xUnit.Net官网上提供的示例代码之一。有兴趣的小伙伴可以去看看,那里还有很多其他的Demo。是不是觉得这个功能很不错呢?接下来我就开始向大家介绍如何实现它吧。
(二)让xUnit.Net识别你的测试Attribute
最开始当然是需要创建一个RetryFact的属性标签了,观察一下Theory的定义。你会发现它是继承自Fact 并作了一些扩展。因此,我们自定义的测试标签页从这里开始,代码如下:
1 [XunitTestCaseDiscoverer("Demo.UnitTest.RetryFact.RetryFactDiscoverer", "Demo.UnitTest")] 2 public class RetryFactAttribute : FactAttribute 3 { 4 /// <summary> 5 /// Number of retries allowed for a failed test. If unset (or set less than 1), will 6 /// default to 3 attempts. 7 /// </summary> 8 public int MaxRetries { get; set; } 9 }
那么,xUnit.Net如何识别我们自定义标签呢?换言之,就是如何知道自定义标签标记的方法是一个需要Run的测试用例?秘密就在前面代码中的XunitTestCaseDiscoverer中。我们需要使用XunitTestCaseDiscoverer标签为自定义的属性类指定一个Discoverer(发现者),并在其中定义返回TestCase的逻辑。代码如下:
1 public class RetryFactDiscoverer : IXunitTestCaseDiscoverer 2 { 3 readonly IMessageSink diagnosticMessageSink; 4 5 public RetryFactDiscoverer(IMessageSink diagnosticMessageSink) 6 { 7 this.diagnosticMessageSink = diagnosticMessageSink; 8 } 9 10 public IEnumerable<IXunitTestCase> Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) 11 { 12 var maxRetries = factAttribute.GetNamedArgument<int>("MaxRetries"); 13 if (maxRetries < 1) 14 { 15 maxRetries = 3; 16 } 17 18 yield return new RetryTestCase(diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), testMethod, maxRetries); 19 } 20 }
代码中添加了对maxRetries初始值修正的逻辑(至少运行3次)。需要说明的是,XunitTestCaseDiscoverer所指定的类应当是实现了IXunitTestCaseDiscoverer接口的(如上面的代码)。该接口定义了一个xUnit.Net Framework用于发现测试用例的方法Discover。其定义如下:
1 namespace Xunit.Sdk 2 { 3 // Summary: 4 // Interface to be implemented by classes which are used to discover tests cases 5 // attached to test methods that are attributed with Xunit.FactAttribute (or 6 // a subclass). 7 public interface IXunitTestCaseDiscoverer 8 { 9 // Summary: 10 // Discover test cases from a test method. 11 // 12 // Parameters: 13 // discoveryOptions: 14 // The discovery options to be used. 15 // 16 // testMethod: 17 // The test method the test cases belong to. 18 // 19 // factAttribute: 20 // The fact attribute attached to the test method. 21 // 22 // Returns: 23 // Returns zero or more test cases represented by the test method. 24 IEnumerable<IXunitTestCase> Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute); 25 } 26 }
此时再回顾一下开始定义的RetryFact属性标签,为它指定了自定义的Test Case Discoverer。so... ... 在xUnit.NetRunner运行Test Case时就可以识别出来我们所自定义的标签了。另外,RetryFactDiscoverer采用了构造函数注入的方式获取到了一个现实了IMessageSink接口的对象,这个对象是用来想Runner传递消息的会在消息总线的部分介绍。
(三)定义运行策略:XunitTestCase
细心的同学应该已经发现,上一部分Discover方法的返回值是一个可枚举类型并且实现了IXunitTestCase接口的对象,xUnit.Net Framework 会以此调用接口的RunAsync方法。我们的例子中返回了自定义的RetryTestCase对象,这一部分我们就来看看它是如何实现的。Discoverer只是告诉xUnit.Net哪些方法是测试方法,而如果想要自定义测试方法运行的时机或者想在运行前后添加处理逻辑的话就需要创建自定义的TestCase类了。这里我们需要实现的逻辑就是根据用户代码在RetryFact中设置的运行次数来重复运行用例,代码如下:
1 namespace Demo.UnitTest.RetryFact 2 { 3 [Serializable] 4 public class RetryTestCase : XunitTestCase 5 { 6 private int maxRetries; 7 8 [EditorBrowsable(EditorBrowsableState.Never)] 9 [Obsolete("Called by the de-serializer", true)] 10 public RetryTestCase() { } 11 12 public RetryTestCase( 13 IMessageSink diagnosticMessageSink, 14 TestMethodDisplay testMethodDisplay, 15 ITestMethod testMethod, 16 int maxRetries) 17 : base(diagnosticMessageSink, testMethodDisplay, testMethod, testMethodArguments: null) 18 { 19 this.maxRetries = maxRetries; 20 } 21 22 23 // This method is called by the xUnit test framework classes to run the test case. We will do the 24 // loop here, forwarding on to the implementation in XunitTestCase to do the heavy lifting. We will 25 // continue to re-run the test until the aggregator has an error (meaning that some internal error 26 // condition happened), or the test runs without failure, or we've hit the maximum number of tries. 27 public override async Task<RunSummary> RunAsync(IMessageSink diagnosticMessageSink, 28 IMessageBus messageBus, 29 object[] constructorArguments, 30 ExceptionAggregator aggregator, 31 CancellationTokenSource cancellationTokenSource) 32 { 33 var runCount = 0; 34 35 while (true) 36 { 37 // This is really the only tricky bit: we need to capture and delay messages (since those will 38 // contain run status) until we know we've decided to accept the final result; 39 var delayedMessageBus = new DelayedMessageBus(messageBus); 40 41 var summary = await base.RunAsync(diagnosticMessageSink, delayedMessageBus, constructorArguments, aggregator, cancellationTokenSource); 42 if (aggregator.HasExceptions || summary.Failed == 0 || ++runCount >= maxRetries) 43 { 44 delayedMessageBus.Dispose(); // Sends all the delayed messages 45 return summary; 46 } 47 48 diagnosticMessageSink.OnMessage(new DiagnosticMessage("Execution of '{0}' failed (attempt #{1}), retrying...", DisplayName, runCount)); 49 } 50 } 51 52 public override void Serialize(IXunitSerializationInfo data) 53 { 54 base.Serialize(data); 55 56 data.AddValue("MaxRetries", maxRetries); 57 } 58 59 public override void Deserialize(IXunitSerializationInfo data) 60 { 61 base.Deserialize(data); 62 63 maxRetries = data.GetValue<int>("MaxRetries"); 64 } 65 } 66 }
上面的代码主要要注意以下几点:
- 自定义的TestCase类最好是继承自XunitTestCase(如果有更深层次的要求可以直接实现IXunitTestCase)。
- 重写基类的RunAsync方法,该方法会在Runner运行Test Case的时候被调用。
- 重写Serialize / Deserialize 方法,像xUnit.Net上下文中添加对自定义属性值的序列化/反序列化的支持。
- 目前,无参构造函数RetryTestCase目前是必须有的(后续的版本中应当会移除掉)。否则,Runner会无法构造无参的Case。
最后,在RunAsync中,我们根据用户设置的次数运行测试用例。如果一直没有成功,则会向消息接收器中添加一个错误的Message(该消息最终会通过消息总线返回给实际的Runner)。可以看到,DelayedMessageBus (代码中 Line38) 是我们自定义的消息总线。
(四)与Runner交流:消息总线 - IMessageBus
在测试用例被xUnit.Net对应的Runner运行的时候,Runner和测试框架的消息沟通是通过消息总线的形式来实现的,这也是很多类似系统都会提供的能力。IMessageBus中定义了向运行xUnit.Net测试用的Runner发送消息的接口方法QueueMessage:
1 namespace Xunit.Sdk 2 { 3 // Summary: 4 // Used by discovery, execution, and extensibility code to send messages to 5 // the runner. 6 public interface IMessageBus : IDisposable 7 { 8 // Summary: 9 // Queues a message to be sent to the runner. 10 // 11 // Parameters: 12 // message: 13 // The message to be sent to the runner 14 // 15 // Returns: 16 // Returns true if discovery/execution should continue; false, otherwise. The 17 // return value may be safely ignored by components which are not directly responsible 18 // for discovery or execution, and this is intended to communicate to those 19 // sub-systems that that they should short circuit and stop their work as quickly 20 // as is reasonable. 21 bool QueueMessage(IMessageSinkMessage message); 22 } 23 }
这里我们自定义的消息总线如下:
1 public class DelayedMessageBus : IMessageBus 2 { 3 private readonly IMessageBus innerBus; 4 private readonly List<IMessageSinkMessage> messages = new List<IMessageSinkMessage>(); 5 6 public DelayedMessageBus(IMessageBus innerBus) 7 { 8 this.innerBus = innerBus; 9 } 10 11 public bool QueueMessage(IMessageSinkMessage message) 12 { 13 lock (messages) 14 messages.Add(message); 15 16 // No way to ask the inner bus if they want to cancel without sending them the message, so 17 // we just go ahead and continue always. 18 return true; 19 } 20 21 public void Dispose() 22 { 23 foreach (var message in messages) 24 innerBus.QueueMessage(message); 25 } 26 }
这里只是简单的对队列中的消息进行了暂存,实际的应用中应该会更复杂。
到此为止,我们已经完成了自定义属性标签的所有的工作。现在系统中已经有了一个叫做RetryTestCase的标签,你可以用它来标记某个测试方法并且提供一个MaxRetries的值。当你运行测试用例的时候他会按照你设置的参数多次运行被标记的测试方法,直到有一次成功或者运行次数超过了最大限制(如果用户代码设置的值小于3的情况下,这里默认会运行3次,Demo而已哈~~~),回顾一下本文开始的那个测试用例:
1 public class RetryFactSample : IClassFixture<CounterFixture> 2 { 3 private readonly CounterFixture counter; 4 5 public RetryFactSample(CounterFixture counter) 6 { 7 this.counter = counter; 8 counter.RunCount++; 9 } 10 11 [RetryFact(MaxRetries = 5)] 12 public void IWillPassTheSecondTime() 13 { 14 Assert.Equal(2, counter.RunCount); 15 } 16 }
每运行一次RunCount会被加1,直到counter.RunCount == 5,运行结构如下:
总结:
这一篇文章应该算是xUnit.Net中比较难理解的一部分。当然也算得上是个里程碑了,搞明白这一部分就相当于了解了一些xUnit.Net的设计和运行原理。也只有这样才有可能真的“玩转”xUnit.Net。否则,仅仅是一个使用者而已,最后回顾一下本文:
- 概述
- 让xUnit.Net识别你的测试Attribute
- 定义运行策略:XunitTestCase
- 与Runner交流:消息总线 - IMessageBus
小北De系列文章:
《[小北De编程手记] : Selenium For C# 教程》
《[小北De编程手记]:C# 进化史》(未完成)
《[小北De编程手记]:玩转 xUnit.Net》(未完成)
Demo地址:https://github.com/DemoCnblogs/xUnit.Net