65.9K
CodeProject 正在变化。 阅读更多。
Home

使用反射调试 xUnit 测试

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2023 年 7 月 3 日

CPOL

8分钟阅读

viewsIcon

7737

downloadIcon

44

设置 xUnit 以便从 Program.Main 运行测试(使用 Visual Studio)

引言

我一直都是手动设置单元测试,当然,这意味着我拥有的测试数量远没有我真正想要的(或需要的)那么多。xUnit 是我遇到的第一个易于使用且无需投入大量时间即可提高生产力的测试框架。它让运行单元测试项目与开发项目变得轻而易举。然而,开箱即用的 xUnit 并不容易将测试与外部系统集成。

我设想将 xUnit 框架作为集成式、自动化、嵌入式硬件/软件测试框架的基础,以便为我们的嵌入式开发带来完全自动化的测试流程。能够集成 xUnit 测试并调试如此庞大的系统将至关重要!

幸运的是,由于 xUnit 非常“透明”,它仅从测试用例构建一个 DLL,然后使用反射调用它们,我们可以轻松地对自己的 Program.Main 做同样的事情,然后它就可以提供正常的调试过程。在做这些的时候,我发现了一些“年轻玩家”的陷阱,并不严重或复杂,结果是一个使用反射的 neat 示例。

我的本职工作是嵌入式开发,而不是 C# 大师,我欢迎任何关于提高我的 C# 技能的建议。我倾向于更明确的编码风格,而不是依赖许多精巧的 C# 转换技巧、除非每天使用否则很难“一眼解析”的超压缩代码。

我所有的 C# 开发都使用 Visual Studio,本文仅与 Visual Studio 相关的经验。我不知道它是否适用于其他 C# IDE(如 VSCode)。我还要假设读者有足够的能力在 Visual Studio 中找到并安装 xUnit Framework,并且这些技巧将有助于从中获得更多优势。最后,作为介绍,我比较老派——我将大括号用作代码块分隔符和视觉块分隔符,您可以随时根据需要重新设置样式。

背景

xUnit

xUnit 测试处理 FactTheory。它们用作测试类方法的 Attribute 类,以区分接受参数的方法(Theory)和不接受参数的方法(Fact)。例如:

   [Theory]
   [InlineData(typeof(int), "42")]
   public void Test2(Type type, string valueStr)
      {
      Assert.Equal(42, int.Parse(valueStr));
      }
​
   [Fact]
   public void Test3()
      {
      }

因此,将参数传递给 Theory 的方法是使用 InlineData 属性。可以有多个 InlineData 属性来针对多个输入数据集运行相同的测试代码(Theory)。

异常用于指示问题,因此,失败应始终抛出异常,并且运行时状态应根据需要使用 Assert 进行符合性测试。不是失败的异常应被捕获并适当地处理!

从代码的角度来看,差不多就是这样了。xUnit 有一个很棒的“Test Explorer”(Test->Test Explorer),它允许您独立于当前启动项目运行测试。这对于调试/检查/测试非常方便;

Reflection(反射)

反射是查看用于创建程序的“反射”元数据的过程。在 .NET 中有大量的这种数据,它由编译器、链接器和运行时生成。在 .NET 中,几乎所有东西都有某种形式的元数据。每个 .NET 应用程序都有包含元数据的类,每个方法也是如此,并且两者都有控制其整个运行时存在的属性。这些属性可以包括开发人员定义的“自定义”属性,作为测试运行时的一部分。

在这里,我们感兴趣的是与类、方法及其方法属性相关的元数据。属性是编译时使用 C# 中的 [] 符号定义的元数据类对象,例如:

[Theory]
[InlineData(typeof(int), "42")]

所有反射元数据本身都是类,这里的 Theory 属性没有构造函数参数,而 InlineData 属性有两个。这些是开发人员定义的“自定义”属性,用于告诉编译器将两个属性对象添加到方法 Test2

xUnit 使用反射提供的元数据,在运行时找出如何加载和运行我们定义的作为测试方法 Test1Test2Test3 的容器的 UnitTest1 类。为了能够运行并调试相同的代码,我们只需要提供一个可以读取元数据并“弄清楚”的 Program.Main

Using the Code

示例代码包含一个 xUnit 测试项目 TestProject1,您需要打开它才能继续。您应该已经为 Visual Studio 安装了 xUnit Test Framework。

Program.Main

开箱即用的 xUnit 提供了这种能力,但它并不容易集成到外部系统中。在这里,我描述了一个 Program.Main,它可以扩展以允许外部控制运行测试。

我们已经知道我们创建的测试类(在 UnitTest1.cs 中)。在我们做什么之前,我们需要创建该类的实例。

var test = new UnitTest1(Output);

稍后我将回来讨论 Output

UnitTest1 类拥有一组我们感兴趣调用的测试方法。我们使用类 Type 反射对象上的 GetMethods() 方法来找到它们,该对象可以通过以下方式访问:

var methods = typeof(UnitTest1).GetMethods();

此方法返回一个 IEnumberable 对象,该对象允许我们遍历方法列表并选择我们要运行的方法。在这里,我使用了 foreach 循环(第 133 行)来查看每个方法的属性列表。可能有一种简单的方法来选择具有 FactTheory 属性的方法,但这里是清晰优先于晦涩(我没有立即清楚 Where 表达式是什么)。

然后,我们还使用另一个 foreach(第 136 行)遍历方法属性。

对于每个方法属性,我们检查它是否是测试方法——是 Fact 还是 Theory(第 139 行)。

Fact 方法处理

这是 Fact 处理程序(第 142 行);

            Output.WriteLine(method.Name);
            Output.Indent++;
            Output.WriteLine(attribute.GetType().Name);
            Output.Indent++;
            if ((skip = ((FactAttribute)attribute).Skip) != null)
               {
               Output.WriteLine("Skipped - " + (string.IsNullOrEmpty(skip) ? 
                                                "no reason given!" : skip));
               Output.Indent.Clear();
               Output.WriteLine();
               continue;
               }
​
            Invoke(test, method);
​
            Output.Indent--;
            Output.WriteLine();

这大部分是输出格式化,您可以通过运行代码和下面的“最终结果”部分自行查看。请注意,如果 Fact 属性是用 string 参数 Skip 声明的,并且 Skip 不是 null string,则该测试将被跳过(如 Test1);

[Theory(Skip = "Fails, needs fixing!")]
[InlineData("\"Type\":\"Print\",\"Mode\":\"WriteLine\",
\"Method\":\"TestFormat0\",\"File\":\"printtTests.cs\",\"Line\":47,\"Indent\":\"\"}")]
public void Test1(string json)

并且将由 xUnit 报告为已跳过

我们在 Program.Main 中复制了 Skip 行为,如下所示:

      if ((skip = ((FactAttribute)attribute).Skip) != null)
         {
         Output.WriteLine("Skipped - " + (string.IsNullOrEmpty(skip) ? 
                                          "no reason given!" : skip));
         Output.Indent.Clear();
         Output.WriteLine();
         continue;
         }​

这里没什么特别的——我们只注意 Skip 并继续下一个测试用例。

如果未跳过,则调用测试方法如下:

         Invoke(test, method);

(参见下面的 Progam.Invoke。)

Theory 方法处理

Theory 方法处理程序与 Fact 方法处理程序非常相似,但有一个额外的复杂性,即它需要处理 InlineData 属性,这些属性用作对测试方法的多次调用的参数。

         Output.WriteLine(method.Name);
         Output.Indent++;
         Output.WriteLine(attribute.GetType().Name);
         Output.Indent++;
         if ((skip = ((TheoryAttribute)attribute).Skip) != null)
            {
            Output.WriteLine("Skipped - " + (string.IsNullOrEmpty(skip) ? 
                                             "no reason given!" : skip));
            Output.Indent.Clear();
            Output.WriteLine();
            continue;
            }
         // Linq: var data = from item in attributes 
         // where item is InlineDataAttribute select item;
         var data = attributes.Where(item => item is InlineDataAttribute);
         foreach (var item in data)
            {
            Output.WriteLine(item.GetType().Name);
            var args = ((InlineDataAttribute)item).GetData
                       (method).ToArray()[0];// array of array of parameters??
​
            Output.Indent++;
            var argEnum = args.Select((arg, index) => new { index, arg });
            foreach (var arg in argEnum)
               Output.WriteLine(arg);
            Output.Indent--;
​
            Invoke(test, method, args);
​
            Output.Indent--;
            Output.WriteLine();
            }​

我们使用以下方法过滤方法属性以仅提取 InlineData 属性:

         var data = attributes.Where(item => item is InlineDataAttribute);

在这里,我使用了 where 子句,因为它很清楚我们要找什么,而无需过多的脑力体操。同样,这是一个 IEnumberable 列表,我们使用 foreach 遍历它,它显示了使用的参数并调用了测试方法。

Program.Invoke

方法调用被提取为一个单独的方法,因为它由两个处理程序调用。它只需使用任何必需的参数调用测试方法的 Invoke 方法。

            method.Invoke(test, args);

这被包装在一个异常处理程序中,该处理程序捕获所有异常以输出通过/失败的等级。

      static void Invoke(UnitTest1 test, MethodInfo method, object[]? args = null)
         {
         Exception? exception = null;
​
         try
            {
            method.Invoke(test, args);
            }
         catch (Exception ex)
            {
            exception = ex;
            }
         Output.Indent--;
​
         if (exception != null)
            {
            for (Exception? ex = exception; ex != null; ex = ex.InnerException)
               Output.WriteLine(ex.ToString());
            Output.WriteLine("Failed");
            }
         else
            Output.WriteLine("Passed");
         }​

这差不多完成了 Program.Main

Output 类

xUnit 在运行测试时重定向和管理标准输出流。如果您想补充输出,需要定义一个继承自 ITestOutputHelper 的输出类。我们定义一个如下:

   public class Output : ITestOutputHelper
      {
      public Indent Indent = new();
​
      public void Write(object message) => Console.Write(message);
      public void Write(string message) => Console.Write(message);
      //
      // Summary:
      //     Adds a line of text to the output.
      //
      // Parameters:
      //   message:
      //     The message
      public void WriteLine(object message) => Console.WriteLine(Indent.Text + message);
​
      //
      // Summary:
      //     Adds a line of text to the output.
      //
      // Parameters:
      //   message:
      //     The message
      public void WriteLine(string message = "") => 
                            Console.WriteLine(Indent.Text + message);
​
      //
      // Summary:
      //     Formats a line of text and adds it to the output.
      //
      // Parameters:
      //   format:
      //     The message format
      //
      //   args:
      //     The format arguments
      public void WriteLine(string format, params object[] args) => 
                            Console.WriteLine(Indent.Text + format, args);
      }

这应该是自明了的,但它增加了通过 Indent 类添加缩进的功能。虽然不是特别聪明,但它具有简单易用的优点。

   public class Indent
      {
      /// <summary>
      /// The number of 'indents' (tab stops) to apply
      /// </summary>
      public int Count { get; internal set; } = 0;
      /// <summary>
      /// The current indent text
      /// </summary>
      public string Text { get; internal set; } = "";
      /// <summary>
      /// The size of each indent (tab size)
      /// </summary>
      public int Size { get; set; } = 3;
      /// <summary>
      /// The char to be used for indentation
      /// </summary>
      public char Char { get; set; } = ' ';
​
      public static Indent operator ++(Indent indent)
         {
         indent.Count++;
         indent.Text = "";
         for (int n = 0; n < indent.Count; n++)
            for (int i = 0; i < indent.Size; i++)
               indent.Text += indent.Char;
         return indent;
         }
​
      public static Indent operator --(Indent indent)
         {
         if (indent.Count != 0)
            {
            indent.Count--;
            indent.Text = "";
            for (int n = 0; n < indent.Count; n++)
               for (int i = 0; i < indent.Size; i++)
                  indent.Text += indent.Char;
            }
         return indent;
         }
​
      public void Clear()
         {
         Count = 0;
         Text = "";
         }
      }

您可以使用它来 IncreaseDecreaseClear 输出缩进级别,例如:

         Output.WriteLine(method.Name);
         Output.Indent++;
         Output.WriteLine(attribute.GetType().Name);

或者

         Output.Indent++;
         Output.WriteLine("Indented");
         Output.Indent.Clear();
         Output.WriteLine("Unindented");

使其工作

此处包含的示例代码应该可以按原样工作。但是,如果您创建一个新的 xUnit 测试项目并想使用此处描述的 Program.Main,有几件事需要您做。首先,您需要将命名空间设置为与您刚创建的项目匹配。

namespace TestProject1

其次,您需要更改用于 Test 方法的类名。

   static void Invoke(UnitTest1 test, MethodInfo method, object[]? args = null)
      var test = new UnitTest1(Output);
   and;
      var methods = typeof(UnitTest1).GetMethods();

第三,不那么明显但很容易修复,您还需要将项目的 Startup 对象设置为 <namespace>.Program。右键单击项目,选择属性,然后设置 Startup object

最终结果

关注点

扩展 xUnit 框架来实现这一点并不难,也许需要半天左右的时间。但它包含了我在 15 年的 C# 编码生涯中以前没有使用过的元素,以及一些在我设置新测试后一段时间未使用时总是会忘记的事情。

这其中一个令人满意方面是,我终于看到了通过扩展此框架以包含自动化来拥有一个有意义的嵌入式测试工具的途径;将硬件特定的测试用例加载和卸载到专用的嵌入式硬件上,捕获输出并分配通过/失败指标。我们开发了一个支持 10 种不同 MCU 的“嵌入式系统”,测试矩阵是一个噩梦,而且非常“手动”。我终于看到了解决这个问题的办法,并在未来几个月内实现自动化。

您的体验可能不同,但无论如何——祝您享受!

历史

  • 2023 年 7 月 3 日:初始版本
© . All rights reserved.