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

  • 相关阅读:
    使用密码解密TACACS+的报文
    C9K Stackwise Virtual(三)
    Webhook Configuration Example
    sup-bootflash和bootflash
    WLC5508 license没有500个?
    AAA Server Groups
    关于FlexConnect的Bug!
    Bug搬运工-CSCve57121--Cisco 2800, 3800 and 1560 series APs fail to pass traffic
    Bug搬运工-CSCvb29354-1810 OEAP cannot join vWLC
    阿里云云计算认证ACP模拟考试练习题第1套模拟题分享(共10套)
  • 原文地址:https://www.cnblogs.com/tianqing/p/12822936.html
Copyright © 2011-2022 走看看