zoukankan      html  css  js  c++  java
  • 如何写好、管好单元测试?基于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("
    ", "");
                                            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

  • 相关阅读:
    HDU 5115 Dire Wolf (区间DP)
    HDU 4283 You Are the One(区间DP(最优出栈顺序))
    ZOJ 3469 Food Delivery(区间DP好题)
    LightOJ 1422 Halloween Costumes(区间DP)
    POJ 1651 Multiplication Puzzle(区间DP)
    NYOJ 石子合并(一)(区间DP)
    POJ 2955 Brackets(括号匹配一)
    POJ 1141 Brackets Sequence(括号匹配二)
    ZOJ 3537 Cake(凸包+区间DP)
    Graham求凸包模板
  • 原文地址:https://www.cnblogs.com/tianqing/p/12822936.html
Copyright © 2011-2022 走看看