内容来自书籍:
Pro ASP.NET Core 6
Develop Cloud-Ready Web Applications Using MVC, Blazor, and Razor Pages (Ninth Edition)Author: Adam Freeman
需要该电子书的小伙伴,可以留下邮箱,有空看到就会发送的
在本章中,我将描述 Web 应用程序开发中使用的 C # 特性,这些特性没有得到广泛的理解,或者经常引起混淆
C # 版本10引入了全局使用语句,该语句允许一次定义一个 using 语句,但在整个项目中生效
新建一个文件GlobalUsings.cs
,名字随便起。然后在里面放入想要全局应用的命名空间,这样整个项目都会隐式使用到这个命名空间
global using LanguageFeatures.Models; global using Microsoft.AspNetCore.Mvc;
ASP.NET Core 项目模板启用一个叫做 implicit usings 的特性,默认全局引入了以下命名空间:
System System.Collections.Generic System.IO System.Linq System.Net.Http System.Net.Http.Json System.Threading System.Threading.Tasks Microsoft.AspNetCore.Builder Microsoft.AspNetCore.Hosting Microsoft.AspNetCore.Http Microsoft.AspNetCore.Routing Microsoft.Extensions.Configuration Microsoft.Extensions.DependencyInjection Microsoft.Extensions.Hosting Microsoft.Extensions.Logging
能够轻松地执行单元测试是使用 ASP.NET Core 的好处之一,但它并不适合所有人,我也不打算假装不适合。我喜欢单元测试,并且我在自己的项目中使用它,但不是所有的项目,也不像您期望的那样始终如一。我倾向于专注于编写特性和函数的单元测试,因为我知道这些特性和函数很难编写,而且很可能是部署过程中 bug 的来源。在这些情况下,单元测试有助于构建我关于如何最好地实现我需要的东西的想法。我发现,仅仅考虑我需要测试的内容就有助于产生关于潜在问题的想法,而且这还是在我开始处理实际的 bug 和缺陷之前。也就是说,单元测试是一种工具,而不是一种信仰,只有您知道您需要多少测试。如果您发现单元测试没有用处,或者如果您有更适合您的不同方法,那么不要仅仅因为单元测试很流行就觉得您需要单元测试。(然而,如果您没有更好的方法,而且根本没有进行测试,那么您可能会让用户找到您的 bug,这很少是理想的。您不必进行单元测试,但是您确实应该考虑进行某种测试。)如果您以前没有遇到过单元测试,那么我建议您尝试一下,看看它是如何工作的
dotnet new globaljson --output Testing/SimpleApp dotnet new web --no-https --output Testing/SimpleApp dotnet new sln -o Testing dotnet sln Testing add Testing/SimpleApp
{ "profiles": { "SimpleApp": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:4399", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } } } }
// Program.cs var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); var app = builder.Build(); // app.MapGet("/", () => "Hello World!"); app.MapDefaultControllerRoute(); app.Run();
namespace SimpleApp.Models; public class Product { public string Name { get; set; } = string.Empty; public decimal? Price { get; set; } public static Product[] GetProducts() { Product kayak = new Product { Name = "Kayak", Price = 275M }; Product lifejacket = new Product { Name = "Lifejacket", Price = 48.95M }; return new Product[] { kayak, lifejacket }; } }
namespace SimpleApp.Controllers; public class HomeController : Controller { public ViewResult Index() { return View(Product.GetProducts()); } }
@using SimpleApp.Models @model IEnumerable<Product> @{ Layout = null; } <!DOCTYPE html> <html> <head> <title>Simple App</title> </head> <body> <div> <ul> @foreach (var p in Model) { <li>Name: @p.Name, Price: @p.Price</li> } </ul> </div> </body> </html>
对于 ASP.NET Core 应用程序,通常需要创建一个单独的 Visual Studio 项目来保存单元测试,每个单元测试都被定义为 C # 类中的一个方法。使用单独的项目意味着您可以部署应用程序,而无需同时部署测试
dotnet new xunit -o SimpleApp.Tests --framework net6.0 dotnet sln add SimpleApp.Tests dotnet add SimpleApp.Tests reference SimpleApp
public class ProductTests { [Fact] public void CanChangeProductName() { // Arrange var p = new Product { Name = "Test", Price = 100M }; // Act p.Name = "New Name"; //Assert Assert.Equal("New Name", p.Name); } [Fact] public void CanChangeProductPrice() { // Arrange var p = new Product { Name = "Test", Price = 100M }; // Act p.Price = 200M; //Assert Assert.Equal(100M, p.Price); } }
按照惯例,测试方法的名称描述测试执行的操作,类的名称描述正在测试的内容。这使得在项目中构造测试以及理解由 IDE 运行时所有测试的结果变得更加容易
将 Fact 属性应用于每个方法以指示它是一个测试
在方法主体中,单元测试遵循一种名为安排、操作、断言(A/A/A)的模式。Arrange 指的是为测试设置条件,act 指的是执行测试,断言指的是验证结果是否符合预期。
常用的断言方法
为 Product 这样的模型类编写单元测试很容易。Product 类不仅简单,而且是自包含的,这意味着当我在 Product 对象上执行操作时,我可以确信我正在测试 Product 类提供的功能。
ASP.NET Core 应用程序中的其他组件的情况更为复杂,因为它们之间存在依赖关系。我定义的下一组测试将在控制器上进行操作,检查在控制器和视图之间传递的 Product 对象序列。
比较自定义的类型是否相等,需要用到接口IEqualityComparer< T >,所以实现这个
public class Comparer { public static Comparer<U?> Get<U>(Func<U?, U?, bool> func) { return new Comparer<U?>(func); } } public class Comparer<T> : Comparer, IEqualityComparer<T> { private Func<T?, T?, bool> comparisonFunction; public Comparer(Func<T?, T?, bool> func) { comparisonFunction = func; } public bool Equals(T? x, T? y) { return comparisonFunction(x, y); } public int GetHashCode(T obj) { return obj?.GetHashCode() ?? 0; } }
然后就可以开始测试Controller
public class HomeControllerTests { [Fact] public void IndexActionModelIsComplete() { // Arrange var controller = new HomeController(); Product[] products = { new() { Name = "Kayak", Price = 275M }, new() { Name = "Lifejacket", Price = 48.95M} }; // Act var model = controller.Index().ViewData.Model as IEnumerable<Product>; // Assert Assert.Equal(products, model, Comparer.Get<Product>((p1, p2) => p1?.Name == p2?.Name && p1?.Price == p2?.Price)); } }
测试通过了,但是它不是一个有用的结果,因为我正在测试的 Product 数据来自硬连接对象的 Product 类。我不能编写一个测试来确保当有两个以上的 Product 对象时,或者如果第一个对象的 Price 属性有一个小数部分时,控制器的行为是正确的。总体效果是,我正在测试 HomeController 和 Product 类的组合行为,并且只针对特定的硬连接对象。
当单元测试针对应用程序的小部分(如单个方法或类)时,它是有效的。我需要的是将 Home 控制器与应用程序的其余部分隔离开来的能力,这样我就可以限制测试的范围,并排除存储库造成的任何影响。
隔离组件的关键是使用 C # 接口。为了将控制器从存储库中分离出来,我将一个名为 IDataSource.cs 的新类文件添加到 Model 文件夹中
public interface IDataSource { IEnumerable<Product> Products { get; } }
实现这个接口,将数据的产生转移到另一种接口
public class ProductDataSource : IDataSource { public IEnumerable<Product> Products => new Product[] { new Product { Name = "Kayak", Price = 275M }, new Product { Name = "Lifejacket", Price = 48.95M } }; }
将数据源注入到控制器(这是IoC应该做的)
public class HomeController : Controller { public IDataSource dataSource = new ProductDataSource(); public ViewResult Index() { return View(dataSource.Products); } }
然后我们的测试就可以变成使用假的数据源来单独测试控制器的行为
public class HomeControllerTests { class FakeDataSource : IDataSource { public FakeDataSource(Product[] data) => Products = data; public IEnumerable<Product> Products { get; set; } } [Fact] public void IndexActionModelIsComplete() { // Arrange Product[] testData = { new() { Name = "P1", Price = 75.10M }, new() { Name = "P2", Price = 120M }, new() { Name = "P3", Price = 110M } }; IDataSource data = new FakeDataSource(testData); var controller = new HomeController(); controller.dataSource = data; // Act var model = controller.Index().ViewData.Model as IEnumerable<Product>; // Assert Assert.Equal(data.Products, model, Comparer.Get<Product>((p1, p2) => p1?.Name == p2?.Name && p1?.Price == p2?.Price)); } }
我遵循了本章中最常用的单元测试风格,即编写一个应用程序特性,然后对其进行测试,以确保它能够按照需要工作。这很流行,因为大多数开发人员首先考虑的是应用程序代码,其次才是测试(这当然是我所属的类别)。
这种方法倾向于产生单元测试,这些单元测试只关注应用程序代码中难以编写的部分或者需要一些严肃的调试的部分,而将某个特性的某些方面仅仅部分测试或者完全未测试。
另一种方法是测试驱动开发(tDD)。TDD 有很多变体,但核心思想是在实现特性本身之前为特性编写测试。首先编写测试可以让您更仔细地思考您正在实现的规范,以及如何知道一个特性已经被正确实现。TDD 不会深入实现细节,而是让您提前考虑成功或失败的度量标准。
您编写的测试最初都会失败,因为您的新特性将不会被实现。但是,当您向应用程序中添加代码时,您的测试将逐渐从红色移动到绿色,并且当特性完成时,所有测试都将通过。TDD 需要规程,但它确实产生了一组更全面的测试,并且可以产生更健壮和更可靠的代码。
通过上面的接口实现,就可以做到mock的效果,但是不是每个接口都这么简单地实现,所以我们需要关于Mock的库
dotnet add SimpleApp.Tests package Moq
然后我们的测试就可以使用Mock数据源来测试
public class HomeControllerTests { [Fact] public void IndexActionModelIsComplete() { // Arrange Product[] testData = { new() { Name = "P1", Price = 75.10M }, new() { Name = "P2", Price = 120M }, new() { Name = "P3", Price = 110M } }; var mock = new Mock<IDataSource>(); mock.SetupGet(m => m.Products).Returns(testData); var controller = new HomeController(); controller.dataSource = mock.Object; // Act var model = controller.Index().ViewData.Model as IEnumerable<Product>; // Assert Assert.Equal(testData, model, Comparer.Get<Product>((p1, p2) => p1?.Name == p2?.Name && p1?.Price == p2?.Price)); mock.VerifyGet(m => m.Products, Times.Once); } }