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