C#学习笔记3-函数与单元测试

现在开始参考书籍变为:《C# 12 and .NET 8 – Modern Cross-Platform Development.Mark Price》

函数

Writing, Debugging, and Testing Functions

  • 写函数
  • Debug
  • 运行时 logging
  • 单元测试

写函数

一个有着 XML 注释的函数

这里直接举一个例子:

Numbers that are used to count are called cardinal numbers(基数), for example, 1, 2, and 3.
Whereas numbers that are used to order are ordinal numbers(序数), for example, 1st, 2nd, and 3rd.

/// 
/// Pass a 32-bit integer and it will be converted into its ordinal equivalent.
/// 
/// Number is a cardinal value e.g. 1, 2, 3, and so on.
/// Number as an ordinal value e.g. 1st, 2nd, 3rd, and so on.
static string CardinalToOrdinal(int number)
{
    switch (number)
    {
        case 11:
        case 12:
        case 13:
            return $"{number}th";
        default:
            string numberAsText = number.ToString();
            char lastDigit = numberAsText[numberAsText.Length - 1];
            string suffix = string.Empty;
            switch (lastDigit)
            {
                case '1':
                    suffix = "st";
                    break;
                case '2':
                    suffix = "nd";
                    break;
                case '3':
                    suffix = "rd";
                    break;
                default:
                    suffix = "th";
                    break;
            }
            return $"{number}{suffix}";
    }
}

留意一下这里初始化字符串:

string suffix = string.Empty;

留意一下上面函数的注释,这样可以利用 vscode 的插件 C# XML documentation comments 形成好看的函数提示。

关于 arguments 和 parameters 的简要说明

A brief aside about arguments and parameters

在日常使用中,大多数开发人员会互换使用术语 arguments 和 parameters。严格来说,这两个术语具有特定且略有不同的含义。但就像一个人可以既是父母又是医生一样,这两个术语通常适用于同一件事。

一个 parameter 是函数定义中的变量(形参),例如下面Hire函数中的 startDate

void Hire(DateTime startDate)
{
	// Function implementation.
}

一旦一个函数被调用。一个 argument 将作为我们传入函数参数的数据(实参)。例如下面的 when_t

DateTime when_t = new(year: 2024, month: 11, day: 5);
Hire(whewhen_tn);

可以在传入实参 argument 时指明形参 parameter ,如下所示:

DateTime when_t = new(year: 2024, month: 11, day: 5);
Hire(startDate: when_t);

当讨论到这次调用时,startDate 是形参 parameter ,when 是实参 argument 。

当我们阅读官方文档时,他们交替使用短语“命名参数和可选参数(named and optional arguments)”以及“命名参数和可选参数(named and optional parameters)”,链接:https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/named-and-optional-arguments

它变得很复杂,因为单个对象可以充当形参和shican ,具体取决于上下文。例如,在 Hire 函数实现中,startDate 参数可以作为实参传递给另一个函数(如 SaveToDatabase),如以下代码所示:

void Hire(DateTime startDate)
{
    ...
    SaveToDatabase(startDate, employeeRecord);
    ...
}

命名事物是计算中最困难的部分之一。

总而言之,形参(parameter)定义了函数的输入;调用函数时,实参(argument)被传递给函数。

良好实践:尝试根据上下文使用正确的术语,但如果其他开发人员“误用”某个术语,请不要对他们学究气。

在函数实现中使用 lambda

Using lambdas in function implementations

在 C# 中,使用 => 表达一个函数的返回值。

例如:

static int FibFunctional(uint term) => term switch
{
    0 => throw new ArgumentOutOfRangeException(),
    1 => 0,
    2 => 1,
    _ => FibFunctional(term - 1) + FibFunctional(term - 2)
};

探索顶级程序、函数和命名空间

Exploring top-level programs, functions, and namespaces

我们之前了解到,自从 C#10 与 .NET 6 之后,控制台的默认的项目模板使用C#9引入的顶级程序特性。

一旦开始编写函数,了解它们如何与自动生成的 Program 类及其

$ 方法一起工作非常重要。

在顶层程序中,本地函数可以定义在文件底部:

using static System.Console;
WriteLine("* Top-level functions example");
WhatsMyNamespace(); // Call the function.

void WhatsMyNamespace() // Define a local function.
{
    WriteLine("Namespace of Program class: {0}",
    	arg0: typeof(Program).Namespace ?? "null");
}

函数不需要位于文件的底部,但这是一种很好的做法,而不是将它们与其他顶级语句混合在一起。类型(如类)必须在 Program.cs 文件的底部而不是在文件的中间声明,否则您将看到编译器错误 CS8803,如以下链接所示:https://learn.microsoft.com/en-us/dotnet/csharp/languagereference/compiler-messages/cs8803.

最好在单独的文件中定义类等类型。

上述代码的运行结果:

* Top-level functions example
Namespace of Program class: null

本地函数自动生成什么?

What is automatically generated for a local function?

编译器会自动生成一个带有

$ 函数的 Program 类,然后将语句和函数移至
$ 方法内,从而使该函数成为本地函数,并重命名该函数,如以下代码中突出显示的那样:

using static System.Console;
partial class Program
{
    static void <Main>$(String[] args)
    {
    	WriteLine("* Top-level functions example");
    	<<Main>$>g__WhatsMyNamespace|0_0(); // Call the function.
    	void <<Main>$>g__WhatsMyNamespace|0_0() // Define a local function.
        {
        WriteLine("Namespace of Program class: {0}",
        arg0: typeof(Program).Namespace ?? "null");
        }
    }
}

为了让编译器知道哪些语句需要去哪里,必须遵循一些规则:

  • 名称空间导入语句(using)必须在 Program.cs 文件的顶部。
  • $ 函数中的语句可以与 Program.cs 文件中间的函数混合。任何函数都将成为
    $ 方法中的本地函数。

最后一点很重要,因为本地函数有局限性,比如它们不能用 XML 注释来记录它们。

定义一个有着 static 函数的 partial Program 类

Defining a partial Program class with a static function

更好的方法是将任何函数编写在单独的文件中,并将它们定义为 Program 类的静态成员:

我们可以在项目新建一个文件,名为 Program.Functions.cs,并在其中定义 partial Program 类:

sing static System.Console;
// 不要定义名称空间,所以这个类在默认空名称空间中
partial class Program
{
    static void WhatsMyNamespace()
    {
        WriteLine("Namespace of Program class: {0}",
        	arg0: typeof(Program).Namespace ?? "null");
    }
}

在 Program.cs 中修改为:

using static System.Console;
WriteLine("* Top-level functions example");
WhatsMyNamespace(); // Call the function.

输出和之前时一样的。

静态函数自动生成了什么

What is automatically generated for a static function?

当我们使用一个额外的单独的文件来定义 partial Program 类以及静态函数。编译器定义了一个有着

$ 函数的 Program 类,并将我们的函数作为 Program 类的成员,如下所示:

using static System.Console;
partial class Program
{
    static void <Main>$(String[] args)
    {
        WriteLine("* Top-level functions example");
        WhatsMyNamespace(); 
    }
    static void WhatsMyNamespace() 
    {
        WriteLine("Namespace of Program class: {0}",
        arg0: typeof(Program).Namespace ?? "null");
    }
}

良好实践:在单独的文件中创建您将在 Program.cs 中调用的任何函数,并在部partial Program 类中手动定义它们。这会将它们合并到与

$ 方法同一级别的自动生成的 Program 类中,而不是作为
$ 方法内的本地函数。

值得注意的是命名空间声明的确实。自动生成的 Program 类和显式定义的 Program 类都位于默认的 null 命名空间中。

警告!不要为您的partial Program 类定义命名空间。如果这样做,它将位于不同的命名空间中,因此不会与自动生成的部分 Program 类融合。

可选地,Program 类中的所有静态方法都可以显式声明为私有(private)方法,但这其实是默认的。由于所有函数都将在 Program 类本身内调用,因此访问修饰符并不重要。

调试

这里需要注意的是 Customizing breakpoints 条件断点。我们可以对设置断点的那个小红点右键,选择编辑断点来设置断点发生时的条件(包括表达式、命中次数(Hit count))。

另外还有一个东西叫 SharpPad,可以用来 Dumping variables

对于经验丰富的 .NET 开发人员来说,他们最喜欢的工具之一是 LINQPad。对于复杂的嵌套对象,可以方便地将其值快速输出到工具窗口中。

尽管其名称如此,LINQPad 不仅适用于 LINQ,还适用于任何 C# 表达式、语句块或程序。如果您在 Windows 上工作,我推荐它,您可以通过以下链接了解更多信息:https://www.linqpad.net。

对于跨平台工作的类似工具,我们将使用 Visual Studio Code 的 SharpPad 扩展。

为了给项目添加 SharpPad 包,我们需要在项目文件夹下的命令行输入:

dotnet add package SharpPad

点开项目 csproj 后发现该包已经被添加进来了:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>ExeOutputType>
    <TargetFramework>net7.0TargetFramework>
    <ImplicitUsings>enableImplicitUsings>
    <Nullable>enableNullable>
  PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SharpPad" Version="1.0.4" />
  ItemGroup>

Project>

需要添加导入:

using SharpPad;
using System.Threading.Tasks;

并将 Main 方法的返回值改为 async Task

using System;
using SharpPad;
using System.Threading.Tasks;
using static System.Console;

namespace Dumping
{
  class Program
  {
    static async Task Main(string[] args)
    {
      var complexObject = new
      {
        FirstName = "Petr",
        BirthDate = new DateTime(
          year: 1972, month: 12, day: 25),
        Friends = new[] { "Amir", "Geoff", "Sal" }
      };

      WriteLine(
        $"Dumping {nameof(complexObject)} to SharpPad.");

      await complexObject.Dump();
    }
  }
}

运行后,complexObject 变量就会被显示出来。但是注意要在 vscode 安装 SharpPad 插件。

VSCode 在 Debug 时使用内部终端

默认 debug 时 VScode 使用内部 DEBUG CONSOLE,不允许使用 ReadLine 这种函数进行交互。

调出左侧调试的侧边栏,带年纪 创建一个 launch.json 文件,选择 C#。

然后点击 增加配置(Add Configuration…)按钮,选择 .NET:Launch .NET Core Console App,进行修改。

  • 注释掉 preLaunchTask
  • program 路径,添加 the Debugging project folder after the workspaceFolder vari-
    able.
  • program 路径,change to net8.0.
  • program 路径,change to Debugging.dll.
  • 更改控制台设置 from internalConsole to integratedTerminal:
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            //"preLaunchTask": "build",
            "program": "${workspaceFolder}/Debugging/bin/Debug/net8.0/
            Debugging.dll",
            "args": [],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            "console": "integratedTerminal"
        }
    ]
}

热重载

Hot reloading during development

热重载是一项功能,允许开发人员在应用程序运行时对代码应用更改并立即看到效果。这对于快速修复错误非常有用。热重载也称为编辑并继续(Edit and Continue)。

您可以在以下链接中找到可以进行的支持热重载的更改类型列表:https://aka.ms/dotnet/hot-reload。

就在 .NET 6 发布之前,一位 Microsoft 高级员工试图将该功能仅限于 Visual Studio,从而引起了争议。幸运的是,微软内部的开源团队成功推翻了这一决定。使用命令行工具仍然可以使用热重载。

热重载,需用以下命令行启动程序:

dotnet watch

在修改完文件保存后,程序即可自动应用新的代码,无需重新启动。

例子:

/* 
* Visual Studio 2022: run the app, change the message, click Hot Reload.
* Visual Studio Code: run the app using dotnet watch, change the
message. */
using static System.Console;
using System.Threading.Tasks;

while (true)
{
	WriteLine("Hello, Hot Reload!");
	await Task.Delay(2000);
}

我们使用 dotnet watch 启动,每 2 秒打印一次 “Hello…”,我们将代码修改为 “GoodBye…”,一会之后我们会发现每 2 秒 打印一次 “GoodBye…”。

日志

一些常见的第三方日志解决方案:

  • Apache log4net
  • NLog
  • Serilog

有两种类型可用于向代码添加简单日志记录:调试(Debug)和跟踪(Trace)。

  • Debug 用于添加在开发过程中写入的日志记录。
  • Trace 用于添加在开发和运行时写入的日志记录

还有一对名为 Debug 和 Trace 的类型。

关于 Debug 类的更多信息: https://docs.microsoft.com/en-us/dotnet/api/system.diagnostics.debug

Debug 和 Trace 类可以写入任何跟踪侦听器(trace listener)。跟踪侦听器是一种可以配置为在调用 Trace.WriteLine 方法时将输出写入指定位置的类型。 .NET Core 提供了多个跟踪侦听器,您甚至可以通过继承 TraceListener 类型来创建自己的跟踪侦听器。

关于 TraceListener 派生的跟踪侦听器列表:https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.tracelistener

写到默认的 trace listener

使用 Trace 和 Debug 需要导入名称空间:

using System.Diagnostics;

DefaultTraceListener 类被自动配置,且将会写到 VSCODE 的 DEBUG CONSOLE (调试控制台)窗口,可以用过代码手动配置。

例如:

using System.Diagnostics;
namespace Instrumenting
{
    class Program
    {
        static void Main(string[] args)
        {
            Debug.WriteLine("Debug says, I am watching!");
            Trace.WriteLine("Trace says, I am watching!");
        }
    }
}

启动调试后,这两条都会在 “调试控制台” 中打印。如果 dotnet run 的话不会输出任何东西。

配置 trace listeners

配置为写到一个文本文件。

需要导入 System.IO 名称空间。

代码如下:

using System.Diagnostics;
using System.IO;
namespace Instrumenting
{
    class Program
    {
        static void Main(string[] args)
        {
            string logPath = Path.Combine(Environment.GetFolderPath(
Environment.SpecialFolder.DesktopDirectory), "log.txt");
            Console.WriteLine($"Writing to: {logPath}");
            TextWriterTraceListener logFile = new(File.CreateText(logPath));
            
            Trace.Listeners.Add(logFile);
            // text writer 是有缓存的,所以可以让 listeners 写完后自动调用 Flush() 刷新缓存。
            #if DEBUG
			Trace.AutoFlush = true;
            #endif
                
            Debug.WriteLine("Debug says, I am watching!");
            Trace.WriteLine("Trace says, I am watching!");
            
            // Close the text file (also flushes) and release resources.
            Debug.Close();
            Trace.Close();
        }
    }
}

输入文件的缓存机制可以提升性能,但是在 dubug 是这种缓存机制可能导致混乱,因为我们可能不能立即看到结果,所以我们让 AutoFlush 为 true。

我们使用下面的命令运行代码:

dotnet run --configuration Release

什么都不会在命令行中打印,而是默默地输出在了 log.txt 文件中:

Trace says, I am watching!

如果我们使用下面的命令运行代码:

dotnet run --configuration Debug

什么都不会在命令行中打印,而是默默地输出在了 log.txt 文件中:

Debug says, I am watching!
Trace says, I am watching!

当使用调试配置运行时,调试和跟踪都处于活动状态,并将在调试控制台中显示其输出。使用发布配置运行时,仅显示跟踪输出。因此,您可以在整个代码中自由地使用 Debug.WriteLine 调用,因为您知道在构建应用程序的发布版本时它们将被自动删除。

选择跟踪级别

Switching trace levels

即使在发布(release)后,Trace.WriteLine 调用仍保留在代码中。因此,如果能很好地控制什么时候输出,那就太好了。这是我们可以通过跟踪开关(trace switch)来完成的事情。

跟踪开关(trace switch)的值可以通过一个数字或词来设置。如下所示:

数字 number 词 word 描述
0 Off 什么也不会输出
1 Error 只输出错误
2 Warning 输出错误和警告
3 Info 输出错误、警告和信息
4 Verbose 输出所有

这里的例子使用了更多的包 package:

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Binder
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.FileExtensions

之后再打开项目 .csproj 文件, 一节中展示了这些添加的包:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>ExeOutputType>
    <TargetFramework>net7.0TargetFramework>
    <ImplicitUsings>enableImplicitUsings>
    <Nullable>enableNullable>
  PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
    <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
  ItemGroup>

Project>

我们在项目目录下添加一个文件 appsettings.json,内容为:

{
  "PacktSwitch": {
    "Value": "Info", // Must be set to work with 7.0.3 or later.
    "Level": "Info" // To work with 7.0.2 or earlier including .NET 6.
  }
}

注意在其 IDE 如 VS2022 或 Rider 中需要将这个配置文件配置到输出目录中。

In Visual Studio 2022 and JetBrains Rider, in Solution Explorer, right-click appsettings.json, select Properties, and then in the Properties window, change Copy to Output Directory to Copy always. This is necessary because unlike Visual Studio Code, which runs the console app in the project folder, Visual Studio runs the console app in Instrumenting\bin\Debug\net8.0 or Instrumenting\bin\Release\net8.0. To confirm this is done correctly, review the element that was added to the project file, as shown in the following markup:

<ItemGroup>
<None Update="appsettings.json">
 <CopyToOutputDirectory>AlwaysCopyToOutputDirectory>
None>
ItemGroup>

The Copy to Output Directory property can be unreliable. In our code, we will read and output this file so we can see exactly what is being processed to catch any issues with changes not being copied correctly.

在代码中导入名称空间:

using Microsoft.Extensions.Configuration;

在 Main 函数中添加配置相关代码:

string settingsFile = "appsettings.json";
string settingsPath = Path.Combine(Directory.GetCurrentDirectory(), settingsFile);

Console.WriteLine("Processing: {0}", settingsPath);
Console.WriteLine("--{0} contents--", settingsFile);
Console.WriteLine(File.ReadAllText(settingsPath));
Console.WriteLine("----");

ConfigurationBuilder builder = new();
builder.SetBasePath(Directory.GetCurrentDirectory());
builder.AddJsonFile(settingsFile,optional: false, reloadOnChange: true);

IConfigurationRoot configuration = builder.Build();

var ts = new TraceSwitch(
	displayName: "PacktSwitch",
	description: "This switch is set via a JSON config.");

configuration.GetSection("PacktSwitch").Bind(ts);

Console.WriteLine($"Trace switch value: {ts.Value}");
Console.WriteLine($"Trace switch level: {ts.Level}");

Trace.WriteLineIf(ts.TraceError, "Trace error");
Trace.WriteLineIf(ts.TraceWarning, "Trace warning");
Trace.WriteLineIf(ts.TraceInfo, "Trace information");
Trace.WriteLineIf(ts.TraceVerbose, "Trace verbose");

Debug.Close();
Trace.Close();
Console.WriteLine("Press enter to exit.");
Console.ReadLine();

用一下命令行运行:

dotnet run --configuration Release

命令行输出:

Processing: XXX\Instrumenting\appsettings.json
--appsettings.json contents--
{
    "PacktSwitch": {
      "Value": "Info", // Must be set to work with 7.0.3 or later.
      "Level": "Info" // To work with 7.0.2 or earlier including .NET 6.
    }
  }
----
Trace switch value: Info
Trace switch level: Info

log.txt

Trace says, I am watching!
Trace error
Trace warning
Trace information

注意,默认的 trace switch 级别为 Off,即都不输出。

如果配置文件没有找到,将会抛出 System.IO.FileNotFoundException 异常。

最后记得释放资源:

Debug.Close();
Trace.Close();
Console.WriteLine("Press enter to exit.");
Console.ReadLine();

在使用完Debug或Trace之前,请注意不要关闭它们。如果你关闭它们后写的都会被丢弃。

记录关于源代码的信息

当您写入日志时,您通常需要包含源代码文件的名称、方法的名称和行号。在 C# 10 及更高版本中,您甚至可以将作为参数传递给函数的任何表达式作为字符串值获取,以便您可以记录它们。

您可以通过用特殊属性修饰函数参数来从编译器获取所有这些信息,如下所示:

[CallerMemberName] string member = ""

Sets the string parameter named member to the name of the method or property that is executing the method that defines this parameter.

将名为 member 的字符串参数设置为正在执行定义此参数的方法的方法或属性的名称。

[CallerFilePath] string filepath = ""

Sets the string parameter named filepath to the name of the source code file that contains the statement that is executing the method that defines this parameter.

将名为 filepath 的字符串参数设置为源代码文件的名称,该文件包含正在执行定义此参数的方法的语句。

[CallerLinebNumber] int line = 0

Sets the int parameter named line to the line number in the source code file of the statement that is executing the method that defines this parameter.

将名为 line int 参数设置为正在执行定义此参数的方法的语句的源代码文件中的行号。

[CallerArgumentExpression(nameof(argumentExpression))] string expression = "":

Sets the string parameter named expression to the expression that has been passed to the parameter named argumentExpression.

将名为 expression 的字符串参数设置为已传递给名为 argumentExpression 的参数的表达式。

您必须通过为这些参数分配默认值来使它们成为可选参数。

示例代码

在项目中创建一个类文件名为 Program.Functions.cs

内容为:

using System.Diagnostics; // To use Trace.
using System.Runtime.CompilerServices; // To use [CallerX] attributes

partial class Program
{
    static void LogSourceDetails(
        bool condition,
        [CallerMemberName] string member = "",
        [CallerFilePath] string filepath = "",
        [CallerLineNumber] int line = 0,
        [CallerArgumentExpression(nameof(condition))] string expression = "")
    {
        Trace.WriteLine(string.Format(
        "[{0}]\n {1} on line {2}. Expression: {3}",
        filepath, member, line, expression));
    }
}

在主文件 Program.cs 中,为类声明前面加 partial 关键字,因为我们在另一个文件( Program.Functions.cs)中添加了 Program 类的其他部分,所以主文件中的类也是个不完整的类,当然,如果 Program.cs 中没有声明类和名称空间,而是直接写的脚本,则不用管这些。

关闭 Debug 和 Trace 前添加:

int unitsInStock = 12;
LogSourceDetails(unitsInStock > 10);

输出的 log.txt 文件中增加了:

[D:\Liyi\coding\C#\StudyCSharp\Chapter04\Instrumenting\Program.cs]
 Main on line 48. Expression: unitsInStock > 10

我们只是在这个场景中编造一个表达式。在实际项目中,这可能是由用户进行用户界面选择以查询数据库等动态生成的表达式。

单元测试

Unit testing

有些开发人员甚至遵循程序员在编写代码之前应该创建单元测试的原则,这称为测试驱动开发(Test-Driven Development,TDD)。

Microsoft 有一个专有的单元测试框架,称为 MSTest。还有一个名为 NUnit 的框架。不过,我们将使用免费开源的第三方框架 xUnit.net。这三个基本上做同样的事情。 xUnit 是由构建 NUnit 的同一团队创建的,但他们修复了他们认为之前犯过的错误。 xUnit 更具可扩展性并且拥有更好的社区支持。

测试的类型

单元测试只是多种测试的一种。

  • 单元测试(Unit testing)

测试最小的代码单元,通常是方法或函数。单元测试是在与其依赖项隔离的代码单元上执行的,如果需要,可以通过模拟它们来执行。每个单元应该有多个测试:一些具有典型输入和预期输出,一些具有极端输入值来测试边界,一些具有故意错误的输入来测试异常处理。

  • 集成测试(Integration testing)

测试较小的单元和较大的组件是否可以作为一个软件一起工作。有时涉及与您没有源代码的外部组件集成。

  • 系统测试(System testing)

测试软件运行的整个系统环境。

  • 性能测试(performance testing)

测试您的软件的性能;例如,您的代码必须在 20 毫秒内向访问者返回充满数据的网页。

  • 加载测试(Load testing)

测试您的软件在保持所需性能的同时可以同时处理多少个请求,例如,网站有 10,000 个并发访问者

  • 用户验收测试(User Acceptance testing)

测试用户是否可以愉快地使用您的软件完成他们的工作。

创建需要测试的类库

Creating a class library that needs testing

本节的例子将会创建一个 类库 项目名为 CalculatorLib

我们将项目下的文件 Class1.cs 重命名为 Calculator.cs,内容改为:

(这里有一个故意的bug,【deliberate,故意的】)

namespace CalculatorLib;

public class Calculator
{
    public double Add(double a,double b)
    {
        return a * b;
    }
}

编译他:

dotnet build

然后添加测试项目(xUnit Test Project [C#] / xunit project),命名为 CalculatorLibUnitTests

命令行可以这么写:

dotnet new xunit -o CalculatorLibUnitTests
dotnet sln add CalculatorLibUnitTests

还要添加项目引用之前要测试的项目CalculatorLib中,可以选择使用命令行:

dotnet add reference ../CalculatorLib/CalculatorLib.csproj

或者直接在CalculatorLibUnitTests.csproj中修改配置文件,增加要给 item group 来引用要测试的项目:

<ItemGroup>
  <ProjectReference Include="..\CalculatorLib\CalculatorLib.csproj" />
ItemGroup>

项目引用的路径可以使用正斜杠 / 或反斜杠 \,因为路径由 .NET SDK 处理,并根据当前操作系统的需要进行更改。

然后构建 CalculatorLibUnitTests 项目。

写单元测试

编写良好的单元测试将由三个部分组成:

  • 安排(Arrange):这部分将声明并实例化输入和输出的变量。
  • 执行(Act):这部分将执行您正在测试的单元。在我们的例子中,这意味着调用我们想要测试的方法。
  • 断言(Assert):这部分将对输出做出一个或多个断言。断言是一种信念 belief ,如果不正确,则表明测试失败。例如,当 2 和 2 相加时,我们期望结果是 4。

现在我们为 Calculator 类写一些单元测试。

重命名 CalculatorLibUnitTests 项目下的 UnitTest1.csCalculatorUnitTests.cs (并对应地修改文件中的类名)。并在其中导入 CalculatorLib 名称空间,然后修改内容:

using CalculatorLib;

namespace CalculatorLibUnitTests;

public class CalculatorUnitTests
{
    [Fact]
    public void TestAdding2And2()
    {
        // Arrange: Set up the inputs and the unit under test.
        double a = 2;
        double b = 2;
        double expected = 4;
        Calculator calc = new();
        // Act: Execute the function to test.
        double actual = calc.Add(a, b);
        // Assert: Make assertions to compare expected to actual results.
        Assert.Equal(expected, actual);
    }
    [Fact]
    public void TestAdding2And3()
    {
        double a = 2;
        double b = 3;
        double expected = 5;
        Calculator calc = new();
        double actual = calc.Add(a, b);
        Assert.Equal(expected, actual);
    }
}

Visual Studio 2022 仍然使用使用嵌套命名空间的旧项目项模板。上面的代码显示了 dotnet new 和 JetBrains Rider 使用的现代项目项模板,该模板使用文件范围的命名空间。(也就是不用 namespace 也用一个大括号包裹了)

运行

在 VSCode 中,如果您最近没有构建测试项目,则构建 CalculatorLibUnitTests 项目以确保 C# Dev Kit 扩展中的新测试功能能够识别您编写的单元测试。

然后点击 查看(view)- 测试(Testing),然后发现左侧多出一个 TESTING 窗口。

点击刷新,然后不断下拉即可找到两个测试函数(注意,测试函数应该有 [Fact] 特性 /Attribute)。

点击上面的"运行测试",就会自动完成两个测试任务。

我们发现目前的代码会导致一个测试函数没有通过。

修 bug

其实是第二个测试函数 TestAdding2And3 写错了,将其中的 expected 改为 6 。

再次运行测试,就发现两个测试都通过了。

抛出与捕获异常

在第 3 章“控制流程、转换类型和处理异常”中,我们向您介绍了异常以及如何使用 try-catch 语句来处理异常。但是,只有当您有足够的信息来缓解问题时,您才应该捕获并处理异常。如果不这样做,那么您应该允许异常通过调用堆栈传递到更高级别。

使用错误与执行错误

Understanding usage errors and execution errors

使用错误(Usage error):使用错误是指程序员误用函数,通常是将无效值作为参数传递。程序员可以通过更改代码以传递有效值来避免它们。当一些程序员第一次学习 C# 和 .NET 时,他们有时认为异常总是可以避免的,因为他们假设所有错误都是使用错误。使用错误应该在生产运行之前全部修复。

执行错误(Execution errors):执行错误是指运行时发生的某些事情无法通过编写“更好”的代码来修复。执行错误可以分为程序错误和系统错误。如果您尝试访问网络资源但网络已关闭,您需要能够通过记录异常来处理该系统错误,并且可能会暂时退出并重试。但有些系统错误,例如内存不足,根本无法处理。如果您尝试打开不存在的文件,您也许能够捕获该错误并通过创建新文件以编程方式处理它。可以通过编写智能代码以编程方式修复程序错误。系统错误通常无法通过编程方式修复。

函数中常见的抛出异常

Commonly thrown exceptions in functions

您很少应该定义新类型的异常来指示使用错误。 .NET 已经定义了许多您应该使用的内容。

当使用参数定义您自己的函数时,您的代码应该检查参数值,如果它们的值会阻止您的函数正常运行,则抛出异常。

例如,如果传给一个函数的一个参数不应该是 null,那么就抛出 ArgumentNullException 异常;对于其他问题,还可以抛出 ArgumentException, NotSupportedException, 或 InvalidOperationException

对于任何异常,请包含一条消息,为必须阅读该消息的任何人(通常是类库和函数的开发人员,或者最终用户)描述问题,如下所示代码:

static void Withdraw(string accountName, decimal amount)
{
    if (string.IsNullOrWhiteSpace(accountName))
    {
    	throw new ArgumentException(paramName: nameof(accountName));
    }
    if (amount <= 0)
    {
    	throw new ArgumentOutOfRangeException(paramName: nameof(amount),message: $"{nameof(amount)} cannot be negative or zero.");
    }
    // process parameters
}

良好实践:如果函数无法成功执行其操作,则应将其视为函数失败并通过抛出异常来报告。

使用保护子句抛出异常

Throwing exceptions using guard clauses

其实可以不适用 new 来实例化一个异常,我们可以使用用异常本身的静态函数。当在函数实现中用于检查参数值时,它们称为保护子句(guard clauses)。其中一些是在 .NET 6 中引入的,更多是在 .NET 8 中添加的。

常见的 guard clauses 如下所示:

ArgumentExceptionThrowIfNullOrEmpty, ThrowIfNullOrWhiteSpace

ArgumentNullExceptionThrowIfNull

ArgumentOutOfRangeExceptionThrowIfEqual, ThrowIfGreaterThan,
ThrowIfGreaterThanOrEqual, ThrowIfLessThan,
ThrowIfLessThanOrEqual, ThrowIfNegative, ThrowIfNegativeOrZero, ThrowIfNotEqual, ThrowIfZero

这样我们就不用 if 判断然后抛出异常了。可以这样:

static void Withdraw(string accountName, decimal amount)
{
	ArgumentException.ThrowIfNullOrWhiteSpace(accountName,
                            paramName: nameof(accountName));
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount,
                            paramName: nameof(amount));
    // process parameters
}

调用堆栈

Understanding the call stack

.NET 控制台应用程序的入口点是 Program 类中的 Main 方法(如果您已显式定义此类)或

$(如果它是由顶级程序功能为您创建的)。

用一个例子展示。先创建一个类库项目 CallStackExceptionHandlingLib

重命名 Class1.csProcessor.cs,修改其内容为:

using static System.Console;

namespace CallStackExceptionHandlingLib;

public class Processor
{
    public static void Gamma() // public s所以它能被外部调用
    {
        WriteLine("In Gamma");
        Delta();
    }
    private static void Delta() // private 所以它只能被内部调用
    {
        WriteLine("In Delta");
        File.OpenText("bad file path");
    }
}

再添加一个控制台项目名为 CallStackExceptionHandling。为该项目添加一个对 CallStackExceptionHandlingLib 类库项目的引用:

dotnet add reference ../CallStackExceptionHandlingLib/CallStackExceptionHandlingLib.csproj

然后项目文件 .csproj 就会多出:

<ItemGroup>
    <ProjectReference Include="..\CallStackExceptionHandlingLib\CallStackExceptionHandlingLib.csproj" />
ItemGroup>

然后,构建 CallStackExceptionHandling 项目来确保依赖项目被编译而且被复制到本地的 bin 目录中。

CallStackExceptionHandling 项目的 Program.cs 中修改内容:

using CallStackExceptionHandlingLib; // To use Processor.
using static System.Console;

WriteLine("In Main");
Alpha();

void Alpha()
{
    WriteLine("In Alpha");
    Beta();
}
void Beta()
{
    WriteLine("In Beta");
    Processor.Gamma();
}

运行后输出为:

In Main
In Alpha
In Beta
In Gamma
In Delta
Unhandled exception. System.IO.FileNotFoundException: Could not find file 'XXX\Chapter04\CallStackExceptionHandling\bad file path'.
File name: 'XXX\Chapter04\CallStackExceptionHandling\bad file path'     
   at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
   at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)  
   at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)      
   at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize, Nullable`1 unixCreateMode)
   at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encoding, Int32 bufferSize)
   at System.IO.File.OpenText(String path)
   at CallStackExceptionHandlingLib.Processor.Delta() in XXX\Chapter04\CallStackExceptionHandlingLib\Processor.cs:line 15
   at CallStackExceptionHandlingLib.Processor.Gamma() in XXX\Chapter04\CallStackExceptionHandlingLib\Processor.cs:line 10
   at Program.<
$>g__Beta|0_1() in XXX\Chapter04\CallStackExceptionHandling\Program.cs:line 15 at Program.<
$>g__Alpha|0_0() in XXX\Chapter04\CallStackExceptionHandling\Program.cs:line 10 at Program.
$(String[] args) in XXX\Chapter04\CallStackExceptionHandling\Program.cs:line 5

请注意,调用堆栈是颠倒的(从下往上从外入内)。从底部开始,你会看到:

  • 第一个调用是自动生成的 Program 类中的
    $ 入口点函数。这是字符串数组参数传入的地方。
  • 第二次调用是 <
    $>g__Alpha|0_0 函数。 (C# 编译器将其添加为本地函数时将其从 Alpha 重命名。)
  • 第三次调用是 Beta 函数(被重命名为 <
    $>g__Beta|0_1()
  • 第四次调用是 Gamma 函数
  • 第五次调用时是 Delta 函数。这里抛出了找不到文件的异常。

良好实践:除非您需要单步执行代码来调试它,否则您应该始终在不附加调试器的情况下运行代码。在这种情况下,不要附加调试器尤其重要,因为如果这样做,它将捕获异常并将其显示在 GUI 对话框中,而不是像书中所示那样输出。

何处捕获异常

Where to catch exceptions

Programmers can decide if they want to catch an exception near the failure point or centralized higher up the call stack. This allows your code to be simplified and standardized. You might know that calling an exception could throw one or more types of exception, but you do not need to handle any of them at the current point in the call stack.

程序员可以决定是否要捕获故障点附近的异常或集中在调用堆栈的较高位置。这使您的代码得以简化和标准化。您可能知道调用异常可能会引发一种或多种类型的异常,但您不需要在调用堆栈中的当前点处理任何异常

重新抛出异常

Rethrowing exceptions

有时您想要捕获异常,记录它,然后重新抛出它。例如,如果您正在编写一个将从应用程序调用的低级类库,您的代码可能没有足够的信息来以编程方式以智能方式修复错误,但调用应用程序可能有更多信息并且能够。您的代码应该记录错误,以防调用应用程序没有记录错误,然后将其重新抛出调用堆栈,以防调用应用程序选择更好地处理它。

有三种方法可以在 catch 块内重新抛出异常,如下列表所示:

  • 要抛出捕获的异常及其原始调用堆栈,请调用 throw
  • 如果像像在调用堆栈中的当前级别抛出一样抛出捕获的异常,请使用捕获的异常调用 throw,例如 throw ex。这通常是不好的做法,因为您丢失了一些可能对调试有用的信息,但当您想要故意删除包含敏感数据的信息时,这可能很有用。
  • 要将捕获的异常包装在另一个异常中,该异常可以在消息中包含更多信息,以帮助调用者理解问题,抛出一个新的异常,并将捕获的异常作为innerException参数传递。

如果我们调用 Gamma 函数时可能发生错误,那么我们可以捕获异常并执行重新抛出异常的三种技术之一,如以下代码所示:

try
{
	Gamma();
}
catch (IOException ex)
{
    LogException(ex);
    // 就像他就这这里发生一样抛出异常
    // 这样将会丢失原本的调用堆栈,不推荐
    throw ex;
    // 重新抛出捕获的异常并保留其原始调用堆栈。
    throw;
    // 抛出一个新的异常,并将捕获的异常嵌套在其中。
    throw new InvalidOperationException(
    message: "Calculation had invalid values. See inner exception for why.",
    innerException: ex);
}

这段代码只是说明性的。你永远不会在同一个 catch 块中使用所有三种技术

我们将之前的 CallStackExceptionHandling 项目的 Program.cs 进行修改:

void Beta()
{
    WriteLine("In Beta");
    try
    {
    	Processor.Gamma();
    }
    catch (Exception ex)
    {
        WriteLine($"Caught this: {ex.Message}");
        throw ex;
    }
}

再次运行:

首先编译器给出警告,指出这样做会丢失信息:

XXX\Chapter04\CallStackExceptionHandling\Program.cs(22,9): warning CA2200: 再次引发捕获到的异常会更改堆栈信息 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2200) 

如果直接用 throw; 就没事了。

相比于上次的输出,多出来了这个:

Caught this: Could not find file 'XXX\Chapter04\CallStackExceptionHandling\bad file path'.

实施测试者-执行者和尝试模式

Implementing the tester-doer and try patterns

测试者-执行者模式(tester-doer pattern)可以避免一些抛出的异常(但不能完全消除它们)。此模式使用一对函数:一个执行测试,另一个执行如果测试未通过则失败的操作

.NET 本身实现了这种模式。例如,在通过调用 Add 方法将一项添加到集合 collection 之前,您可以测试它是否是只读的,这会导致 Add 失败,从而引发异常

例如,在从银行账户提款之前,您可能会测试该账户是否没有透支,如以下代码所示:

if (!bankAccount.IsOverdrawn())
{
	bankAccount.Withdraw(amount);
}

测试者-执行者模式会增加性能开销,因此您还可以实现 try 模式(try pattern),该模式实际上将测试和执行部分组合到单个函数中,正如我们在 TryParse 中看到的那样。

当您使用多个线程时,测试者-执行者模式会出现另一个问题。在这种情况下,一个线程调用测试函数,它返回一个值,表明可以继续。但随后另一个线程执行,这会改变状态。然后原来的线程继续执行,假设一切都很好,但其实并不好。这称为条件竞争。这个主题太高级了,本书无法介绍如何处理它。

良好实践:优先使用尝试模式而不是测试者-执行者模式。

如果您实现自己的 try 模式函数并且失败,请记住将 out 参数设置为其类型的默认值,然后返回 false,如以下代码所示:

static bool TryParse(string? input, out Person value)
{
    if (someFailure)
    {
        value = default(Person);
        return false;
    }
    // Successfully parsed the string into a Person.
    value = new Person() { ... };
    return true;
}

更多信息:了解更多关于异常详细信息:https://learn.microsoft.com/en-us/dotnet/standard/exceptions/

你可能感兴趣的:(c#,学习,笔记)