如何寫好、管好單元測試?基於Roslyn+CI分析單元測試,嚴控產品提測質量
上一篇文章中,我們談到了通過Roslyn進行程式碼分析,通過自定義程式碼掃描規則,將有問題的程式碼、不符合編碼規則的程式碼掃描出來,禁止簽入,提升團隊的程式碼質量。
.NET Core技術研究-通過Roslyn全面提升程式碼質量
今天我們基於第二篇:基於Roslyn技術,掃描單元測試程式碼,通過單元測試覆蓋率和執行通過率,嚴控產品提測質量,覆蓋率和通過率達不到標準,無法提交測試。
首先,我們先討論一下,什麼是單元測試,單元測試的覆蓋率統計。
一、什麼是單元測試
單元測試(unit testing),是指對軟體中的最小可測試單元進行檢查和驗證。對於單元測試中單元的含義,一般來說,要根據實際情況去判定其具體含義,如C語言中單元指一個函式,C#裡單元指一個類,圖形化的軟體中可以指一個視窗或一個選單等。
總的來說,單元就是人為規定的最小的被測功能模組。同時,
單元測試是在軟體開發過程中要進行的最低級別的測試活動,軟體的獨立單元將在與程式的其他部分相隔離的情況下進行測試。
在實際研發中,很多團隊浪費大量的時間和精力編寫單元測試,那麼“好的”單元測試是什麼樣的呢?
- 好的單元測試可以覆蓋應用程式行為的不同情況和方面
- 好的單元測試應該結構良好的程式碼,包含測試準備、執行、斷言、測試清理
- 好的單元測試每個都只測試一個最新的功能、程式碼單元
- 好的單元測試是獨立和隔離的:什麼都不依賴,不訪問全域性狀態,檔案系統或資料庫。
- 好的單元測試是可描述的:見名知意。
- 好的單元測試是可重複執行的:無論何時執行,無論在何處執行都通過。
- 好的單元測試執行的很快
二、如何管理、評估單元測試的覆蓋率和通過率
業界通常的做法有:
1. 程式碼行數覆蓋率
2. 類、方法、條件分支覆蓋率
3. 單元測試型別覆蓋情況:正常、異常、效能、邊界
4. 業務場景覆蓋情況
但是會產生對單元測試一些誤區、錯誤理解:
- 覆蓋率資料只能代表你測試過哪些程式碼,不能代表你是否測試好這些程式碼。(比如上面第一個除零Bug)
- 不要過於相信覆蓋率資料。
- 不要只拿語句覆蓋率(行覆蓋率)來考核研發交付質量
- 路徑覆蓋率 > 判定覆蓋 > 語句覆蓋
- 開發人員不能盲目追求程式碼覆蓋率,而應該想辦法設計更多更好的用例,哪怕多設計出來的用例對覆蓋率一點影響也沒有
經過內部架構師團隊的技術交流和討論,我們達成了以下共識:
我們如何寫好、用好、管好單元測試?
- 面:覆蓋核心微服務的實現,即:核心重要的微服務必須覆蓋單元測試
- 點:單元測試場景要儘可能地覆蓋
- 結構:單元測試要有完備的斷言
- 型別:單元測試儘可能的覆蓋正常、異常、效能、邊界
- 可設計評估:概要設計時,確定並錄入功能的單元測試場景,開發完成提測時保證單元測試覆蓋率
- 管理:單元測試情況能全面上報管理起來,以進一步控制開發交付的質量
- 通過率:100%通過方可發起CI,生成補丁
在此基礎上,我們啟動了今年單元測試推動工作,主要的方案是這樣的:
1. 增加一個單元測試註解,將一些關鍵的業務屬性進行標註、上報,比如:微服務標識、微服務型別、單元測試集、單元測試說明、負責人、單元測試型別(正常、異常、效能、邊界等)
2. CI持續整合時,必須執行單元測試工程,通過將單元測試執行結果上報到研發效能平臺,保障後續補丁提測時控制單元測試通過率,同時單元測試通過率低於95%,無法生成補丁
3. 單元測試統一在研發效能平臺中管理,即支援單元測試資訊上報到管理平臺中,方便後續程式碼提測時進行:核心微服務單元測試覆蓋率控制
通過以上系統約束+管理規定,實現產品提測質量的控制。如何實現上述三個關鍵技術點呢?
增加單元測試註解、掃描單元測試註解情況、上報單元測試到研發效能平臺。
接下來,第三部分,我們將引入Roslyn來完成單元測試程式碼分析
三、增加單元測試註解,讓單元測試具備更多有價值的資訊
正如上面所講,我們增加一個了單元測試註解,將一些關鍵的業務屬性進行標註、上報,比如:微服務標識、微服務型別、單元測試集、單元測試說明、負責人、單元測試型別(正常、異常、效能、邊界等)。
UnitTestAttribute
有了自定義單元測試註解後,我們將這個單元測試註解,打到了單元測試方法上:
單元測試方法有了更多的業務資訊之後,我們就可以基於Roslyn實現單元測試程式碼分析了。
四、基於Roslyn實現單元測試程式碼分析
現有的業務程式碼到底有多少單元測試,是否全部完成了UnitTest單元測試註解改造,這個統計工作很重要。
這裡就用到了Roslyn程式碼分析技術,大家可以參考第一篇中對Roslyn的詳細介紹:.NET Core技術研究-通過Roslyn全面提升程式碼質量
基於Roslyn實現單元測試程式碼分析,並將分析後的結果上報到研發效能平臺,這樣就實現了單元測試資料集中管理,方便後續分析和改進。
通過Roslyn實現單元測試方法的分析過程主要有:
① 建立一個編譯工作區MSBuildWorkspace.Create()
② 開啟解決方案檔案OpenSolutionAsync(slnPath);
③ 遍歷Project中的Document
④ 拿到程式碼語法樹、找到所有的方法
⑤ 判斷方法是否有UnitTest註解,如果有,將單元測試註解資訊統計並上報
看一下實際的程式碼:
public async Task<List<CodeCheckResult>> CheckSln(string slnPath) { var results = new List<CodeCheckResult>(); try { var slnFile = new FileInfo(slnPath); var solution = await MSBuildWorkspace.Create().OpenSolutionAsync(slnPath); if (solution.Projects != null && solution.Projects.Count() > 0) { foreach (var project in solution.Projects.ToList()) { var documents = project.Documents.Where(x => x.Name.Contains(".cs")); foreach (var document in documents) { var tree = await document.GetSyntaxTreeAsync(); var root = tree.GetCompilationUnitRoot(); if (root.Members == null || root.Members.Count == 0) continue; //member var classDeclartions = root.DescendantNodes().Where(i => i is ClassDeclarationSyntax); foreach (var classDeclare in classDeclartions) { var programDeclaration = classDeclare as ClassDeclarationSyntax; if (programDeclaration == null) continue; foreach (var method in programDeclaration.Members) { if (method.GetType() != typeof(MethodDeclarationSyntax)) continue; //方法 Method var methodDeclaration = (MethodDeclarationSyntax)method; var testAnnotations = methodDeclaration.AttributeLists.Where(i => i.Attributes.FirstOrDefault(a => a.Name.GetText().ToString() == "TestMethod") != null); var teldUnitTestAnnotation = methodDeclaration.AttributeLists.FirstOrDefault(i => i.Attributes.FirstOrDefault(a => a.Name.GetText().ToString() == "UnitTest") != null); if (testAnnotations.Count() > 0) { var result = new UnitTestCodeCheckResult() { Sln = slnFile.Name, ProjectName = project.Name, ClassName = programDeclaration.Identifier.Text, MethodName = methodDeclaration.Identifier.Text, }; if (methodDeclaration.Body.GetText().Lines.Count <= 3) { result.IsEmptyMethod = true; } var methodBody = methodDeclaration.Body.GetText().ToString(); methodBody = methodBody.Replace("{", ""); methodBody = methodBody.Replace("}", ""); methodBody = methodBody.Replace(" ", ""); methodBody = methodBody.Replace("\r\n", ""); if (methodBody.Length == 0) { result.IsEmptyMethod = true; } if (teldUnitTestAnnotation != null) { result.IsTeldUnitTest = true; var args = teldUnitTestAnnotation.Attributes.FirstOrDefault().ArgumentList.Arguments; result.UnitTestCase = args[0].GetText().ToString(); result.SeqNo = args[1].GetText().ToString(); result.UnitTestName = args[2].GetText().ToString(); result.UserName = args[3].GetText().ToString(); result.ServiceType = args[4].GetText().ToString().Replace(" ", ""); if (args.Count >= 7) { result.ServiceID = args[5].GetText().ToString(); result.UnitTestType = args[6].GetText().ToString(); } } results.Add(result); } } } } } } return results; } catch (Exception e) { Console.WriteLine(e.ToString()); return results; } }
上述程式碼中,最關鍵的是以下兩句:
var methodDeclaration = (MethodDeclarationSyntax)method; var testAnnotations = methodDeclaration.AttributeLists.Where(i => i.Attributes.FirstOrDefault(a => a.Name.GetText().ToString() == "TestMethod") != null); var teldUnitTestAnnotation = methodDeclaration.AttributeLists.FirstOrDefault(i => i.Attributes.FirstOrDefault(a => a.Name.GetText().ToString() == "UnitTest") != null);
快速定位到打了UnitTest註解的單元測試方法,然後將註解的資訊掃描上報:
if (teldUnitTestAnnotation != null) { result.IsTeldUnitTest = true; var args = teldUnitTestAnnotation.Attributes.FirstOrDefault().ArgumentList.Arguments; result.UnitTestCase = args[0].GetText().ToString(); result.SeqNo = args[1].GetText().ToString(); result.UnitTestName = args[2].GetText().ToString(); result.UserName = args[3].GetText().ToString(); result.ServiceType = args[4].GetText().ToString().Replace(" ", ""); if (args.Count >= 7) { result.ServiceID = args[5].GetText().ToString(); result.UnitTestType = args[6].GetText().ToString(); } }
具體上報的程式碼在這裡不做詳細的描述了,大致的思路就是通過HttpClient傳送JSON資料到研發效能平臺中。
完成單元測試上報後,研發效能平臺中就有了單元測試的基礎資訊了,基於這個資料,就可以實現核心微服務單元測試覆蓋率統計和控制了。
然後,如何統計單元測試的執行通過率呢?
五、統計上報單元測試執行情況,並控制補丁是否滿足提測要求
上一個章節中,我們提到了“程式碼check in後,開發人員可以通過CI觸發持續構建,生成補丁,在這個CI過程中,按照要求必須新增一步單元測試掃描的工作”,同時,CI的過程中必須執行單元測試。如何獲取到單元測試的執行結果?
這裡我們增加了一個單元測試父類:TUnitTest,在父類中實現了單元測試執行結果統計和上報:
/// <summary> /// 單元測試基類,業務單元測試繼承該類 /// </summary> [TestClass] public abstract class TUnitTest { bool isReportData = true; /// <summary> /// 建構函式 /// </summary> public TUnitTest() { // //TODO: 在此處新增建構函式邏輯 // } private TestContext testContextInstance; public System.Diagnostics.Stopwatch _stopWatch; /// <summary> ///獲取或設定測試上下文,該上下文提供 ///有關當前測試執行及其功能的資訊。 ///</summary> public TestContext TestContext { get { return testContextInstance; } set { testContextInstance = value; } } #region 附加測試特性 /// <summary> /// 在每個測試執行完之後,使用 TestCleanup 來執行程式碼 /// </summary> [TestCleanup()] public virtual void TestCleanup() { var recordFilePath = ""; if (!isReportData) { Console.WriteLine("TESTREPORTIPS:TestCleanup設定為不上報,請檢查TestInitialize程式碼處理"); return; } if (this.TestContext != null) { try { var tt = this.GetType(); var testClass = this.TestContext.FullyQualifiedTestClassName; var type = this.GetType(); var tenantID = Convert.ToString(this.TestContext.Properties["TenantID"]); var batchID = Convert.ToString(this.TestContext.Properties["BatchID"]); var testMethod = type.GetMethod(this.TestContext.TestName); if (testMethod != null) { var utAttr = testMethod.GetCustomAttributes(false).FirstOrDefault(i => i.GetType() == typeof(UnitTestAttribute)); if (utAttr != null) { var unitTestAttr = utAttr as UnitTestAttribute; var testcase = new UnitTestCase { SequenceNumber = unitTestAttr.SequenceNumber, Description = unitTestAttr.Description, Passed = this.TestContext.CurrentTestOutcome == UnitTestOutcome.Passed, ExecuteTime = Convert.ToDateTime(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")), HostName = System.Net.Dns.GetHostName(), ServiceID = unitTestAttr.ServiceID, ServiceType = unitTestAttr.ServiceType, Tag = unitTestAttr.Tag, UnitTestName = unitTestAttr.UnitTestName, UserName = unitTestAttr.UserName, TestSuiteCode = unitTestAttr.TestSuiteCode, UnitTestAssembly = tt.Assembly.FullName, UnitTestClass = testClass, UnitTestMethod = testMethod.Name, UnitTestType = unitTestAttr.UnitTestType, TenantID = tenantID, BatchID = batchID, }; testcase.TestFile = UnitTestUtil.GetUnitTestFile(tt.Assembly.Location,ref recordFilePath); if (_stopWatch != null) testcase.Duration = Math.Round(_stopWatch.Elapsed.TotalSeconds, 2); UnitTestCaseManager.Report(testcase); Console.WriteLine($"TestCleanup執行{testcase.TestSuiteCode}-{testcase.SequenceNumber}-{testcase.UnitTestName}上報完成"); } else { Console.WriteLine($"TESTREPORTIPS:TestCleanup執行上報時測試方法{testMethod.Name}未配置UnitTestAttribute的註解"); } } else { Console.WriteLine($"TESTREPORTIPS:TestCleanup執行上報時未能通過{this.TestContext.TestName}獲取到測試方法"); } } catch (Exception ex) { if (!string.IsNullOrEmpty(recordFilePath) && File.Exists(recordFilePath)) { try { File.Delete(recordFilePath); } catch { } } Console.WriteLine("TestCleanup執行異常: " + ex.ToString()); } finally { if (_stopWatch != null) _stopWatch = null; } } else { Console.WriteLine("TESTREPORTIPS:TestCleanup執行異常:context為空"); } } }
如上程式碼所述,在每個測試執行完之後,TestCleanup 方法中進行了以下操作:
① 獲取當前單元測試方法的自定義UnitTest註解資訊
② 獲取單元測試執行是否通過
③ 獲取單元測試程式碼內容,這一步可以做一些Assert檢查,方法是否為空檢查,實現有效的單元測試程式碼合理性控制
④ 將單元測試執行資訊上報到研發效能平臺
⑤ 完成輸出一些提示資訊,方便排查問題
有了這個父類後,所有的單元測試類,都繼承與TUnitTest,實現單元測試執行情況上報。
單元測試執行通過率如果低於某個設定值的話,可以控制在CI的過程中是否生產補丁。
同時,研發效能平臺中,有了單元測試資料,單元測試註解改造資料,單元測試執行資料,可以實現補丁提測前二次質量控制:即單元測試覆蓋率和執行通過率控制,如果達不到要求,補丁無法提測,進而實現產品提測質量的控制。
以上是如何寫好、用好、管好單元測試,基於Roslyn分析單元測試,嚴控產品提測質量的一些實踐分享。
周國慶
2020/5/11