1. 程式人生 > >如何寫好、管好單元測試?基於Roslyn+CI分析單元測試,嚴控產品提測質量

如何寫好、管好單元測試?基於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