测试 ASP.NET Core MVC 应用

小窍门

此内容摘自电子书、使用 ASP.NET Core 和 Azure 构建新式 Web 应用程序,可在 .NET Docs 上获取或作为可脱机阅读的免费可下载 PDF。

架构现代 Web 应用程序:使用 ASP.NET Core 和 Azure 的电子书封面缩略图。

“如果你不喜欢对产品进行单元测试,客户很可能也不喜欢测试它。 _-匿名-

任何复杂程度的软件在响应更改方面皆可能意外失败。 因此,更改后,除最普通(或关键性最低)的应用程序外,其他所有应用程序均需测试。 手动测试是最慢、最不可靠且最昂贵的软件测试方式。 遗憾的是,如果应用程序没有设计为适合测试,那这可能是唯一可用的测试方法。 编写为遵循第 4 章 中所述的体系结构原则的应用程序应基本上可以进行单元测试。 ASP.NET 核心应用程序支持自动集成和功能测试。

自动测试的类型

软件应用程序自动测试具有多种类型。 最简单最低级别的测试是单元测试。 级别稍高的测试包括集成测试和功能测试。 其他类型的测试(如 UI 测试、负载测试、压力测试和冒烟测试)超出了本文档的范围。

单元测试

单元测试测试应用程序的逻辑的单个部分。 可通过否定列举方式对其进行进一步描述。 单元测试并不测试代码如何处理依赖项或基础结构 - 这是集成测试的用途。 单元测试不会测试编写代码的框架 - 你应该假设它有效,或者,如果发现它不起作用,请提交 bug 并编写一个解决方法。 单元测试完全在内存和进程环境中运行。 它不与文件系统、网络或数据库通信。 单元测试应仅测试代码。

单元测试,因为它们仅测试一个代码单元(没有外部依赖项)应该执行极快。 因此,你应该能够在几秒钟内运行数百个单元测试的测试套件。 请经常运行单元测试,最好是在每次推送到共享源代码管理存储库前运行,当然还要在生成服务器上的每个自动生成时运行它们。

集成测试

尽管建议封装与数据库和文件系统等基础结构交互的代码,但是仍会剩下一些此类代码,你可能需要对其进行测试。 应用程序依赖项完全解析时,还应验证代码层是否按预期方式交互。 此功能是集成测试的目的。 集成测试往往比单元测试更慢且更难设置,因为它们通常依赖于外部依赖项和基础结构。 因此,应避免在集成测试中测试那些可以用单元测试来验证的内容。 如果可以使用单元测试来测试给定的方案,则应使用单元测试对其进行测试。 如果无法,请考虑使用集成测试。

与单元测试相比,集成测试通常具有更复杂的设置和拆解过程。 例如,针对实际数据库的集成测试需要在每次测试运行之前将数据库返回到已知状态。 随着新测试的添加和生产数据库架构的演变,这些测试脚本在大小和复杂性方面往往会增加。 在许多大型系统中,在签入共享源代码管理更改之前,在开发人员工作站上运行完整的集成测试套件是不切实际的。 在这些情况下,集成测试可能在生成服务器上运行。

功能测试

集成测试是从开发人员的角度编写的,以验证系统的某些组件是否正常工作。 从用户的角度编写功能测试,并根据系统要求验证系统的正确性。 与单元测试相比,以下摘录提供了一个有用的类比,用于思考功能测试:

“很多时候,系统的发展被比化为房子的建设。 虽然这种类比不太正确,但为了了解单元测试和功能测试之间的差异,我们可以扩展它。 单元测试就像建筑检查员在房屋施工现场进行检查一样。 他专注于房子的各种内部系统,基础,框架,电气,管道等。 他确保(测试)房屋的各个部分将正常工作和安全,即满足建筑代码。 在这种情况下,功能测试就像房主亲自到同一建筑工地视察一样。 他认为内部系统的行为将适当,建筑检查员正在执行他的任务。 房主关心的是住在这个房屋里的体验。 他关心房子的外观,各个房间的大小是否舒适,房子是否符合家庭的需要,窗户的位置是否能捕捉到清晨的阳光。 房主正在对房子执行功能测试。 他是站在用户角度。 建筑检查员正在对房子执行单元测试。 他有建筑商的视角。

来源: 单元测试与功能测试

我常说:“作为开发人员,我们有两种失败方式:一种是我们构建的东西有问题,另一种是我们构建的是不正确的东西。单元测试保证我们正确构建功能;功能测试保证我们构建的是正确的东西。”

由于功能测试在系统级别运行,因此可能需要某种程度的 UI 自动化。 与集成测试一样,它们通常也适用于某种测试基础结构。 此活动使它们比单元和集成测试更慢、更脆弱。 你应该只进行必要数量的功能测试,以确保系统的行为符合用户的预期。

测试金字塔

马丁·福勒写道,测试金字塔是图 9-1 中显示的一个例子。

测试金字塔

图 9-1. 测试金字塔

棱锥图的不同层及其相对大小表示不同类型的测试,以及应为应用程序编写的数量。 如图所示,建议以大量单元测试作为基层,中间以较小的集成测试层作为支持,顶端为更小的功能测试层。 理想情况下,每一层应该只包含无法在较低层充分执行的测试。 尝试确定特定方案所需的测试类型时,请记住测试棱锥图。

要测试的内容

对于不熟悉编写自动测试的开发人员来说,一个常见问题是想出要测试的内容。 一个很好的起点是测试条件逻辑。 无论在哪里,如果方法的行为会根据条件语句(如 if-else、switch 等)发生变化,你应该能够至少设计几个测试来验证某些条件下的正确行为。 如果代码有错误条件,则最好通过代码(没有错误)为“快乐路径”编写至少一个测试,并且至少一个测试“可悲路径”(错误或非典型结果),以确认应用程序在遇到错误时的行为符合预期。 最后,尝试专注于测试可能失败的事情,而不是专注于代码覆盖率等指标。 通常来说,更多的代码覆盖率优于较少的代码覆盖率。 但是,通常情况下,对复杂和业务关键方法编写更多测试比单纯为了提升测试代码覆盖率指标而对自动属性编写测试,是更好的时间利用。

组织测试项目

可以根据最适合您的方式来组织测试项目。 最好按类型(单元测试、集成测试)和测试(按项目、按命名空间)分隔测试。 此分离由单个测试项目内的文件夹构成,还是由多个测试项目构成,这取决于设计决策。 一个项目最简单,但对于具有许多测试的大型项目,或者为了更轻松地运行不同的测试集,你可能希望有多个不同的测试项目。 许多团队根据他们正在测试的项目组织测试项目,对于具有多个项目的应用程序,可能会导致大量的测试项目,尤其是在你仍然根据每个项目中的测试类型细分这些项目时。 妥协方法是让每种类型的测试项目(每个应用程序)有一个项目,测试项目中的文件夹用于指示要测试的项目(和类)。

一种常见方法是在“src”文件夹下组织应用程序项目,并在并行“测试”文件夹下组织应用程序的测试项目。 如果发现此组织很有用,可以在 Visual Studio 中创建匹配的解决方案文件夹。

解决方案中的测试组织

图 9-2. 解决方案中的测试组织

可以使用你喜欢的测试框架。 xUnit 框架运行良好,是所有 ASP.NET Core 和 EF Core 测试所用的框架。 可以在 Visual Studio 中使用图 9-3 中显示的模板添加 xUnit 测试项目,或通过 CLI 使用 dotnet new xunit 来添加。

在 Visual Studio 中添加 xUnit 测试项目

图 9-3. 在 Visual Studio 中添加 xUnit 测试项目

测试命名

以一致的方式命名测试,其中的名称指示每个测试的作用。 我非常成功的一种方法是根据测试类和测试方法命名测试类。 这种方法会导致许多小型测试类,但它极其清晰地阐明了每个测试的职责。 设置测试类名称后,若要标识要测试的类和方法,可以使用测试方法名称来指定要测试的行为。 此名称应包含预期行为以及应生成此行为的输入或假设。 一些示例测试名称:

  • CatalogControllerGetImage.CallsImageServiceWithId

  • CatalogControllerGetImage.LogsWarningGivenImageMissingException

  • CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess

  • CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException

此方式的一种变体是让每个测试类名称以“Should”结尾,并稍微修改其时态:

  • CatalogControllerGetImage 应该.调用ImageServiceWithId

  • CatalogControllerGetImage 应该.记录WarningGivenImageMissingException

一些团队发现第二种命名方法更清晰,不过稍微详细一些。 在任何情况下,都应尝试采用一种能够揭示测试行为的命名规则,以便在一个或多个测试失败时,可以从其名称中清楚地看出哪些情况出现了问题。 避免模糊命名您的测试,例如 ControllerTests.Test1,因为这些名称在测试结果中没有意义。

如果遵循上述生成许多小型测试类的命名约定,最好使用文件夹和命名空间进一步组织测试。 图 9-4 显示了一种按文件夹在多个测试项目中组织测试的方法。

根据要测试的类按文件夹组织测试类

图 9-4. 根据要测试的类按文件夹组织测试类。

如果特定应用程序类具有许多要测试的方法(因此许多测试类),则将这些类放置在与应用程序类对应的文件夹中可能有意义。 该组织方式与在其他情况下将文件组织到文件夹并无差别。 如果包含许多其他文件的文件夹中有三个或四个相关文件,通常建议将它们移动到单独的子文件夹中。

ASP.NET 核心应用的单元测试

在设计良好的 ASP.NET 核心应用程序中,大部分复杂性和业务逻辑将封装在业务实体和各种服务中。 ASP.NET Core MVC 应用本身及其控制器、筛选器、viewmodel 和视图应该需要很少的单元测试。 给定作的大部分功能都位于作方法本身之外。 使用单元测试无法有效地测试路由和全局错误处理是否正确运行。 同样,任何筛选器,包括模型验证、身份验证和授权筛选器,都不能通过针对控制器的动作方法的测试进行单元测试。 如果没有这些行为源,大多数操作方法应非常小,这会将其大量工作委托至服务(可独立于使用这些服务的控制器对这些服务执行测试)。

有时需要重构代码才能对代码进行单元测试。 通常,此活动涉及识别抽象并使用依赖项注入访问要测试的代码中的抽象,而不是直接针对基础结构进行编码。 例如,请考虑使用此简单的动作方法来显示图像:

[HttpGet("[controller]/pic/{id}")]
public IActionResult GetImage(int id)
{
  var contentRoot = _env.ContentRootPath + "//Pics";
  var path = Path.Combine(contentRoot, id + ".png");
  Byte[] b = System.IO.File.ReadAllBytes(path);
  return File(b, "image/png");
}

通过 System.IO.File 上的直接依赖项难以对此方法执行单元测试。 可以测试此行为以确保它按预期工作,但使用实际文件进行此行为属于集成测试。 值得注意的是,无法对该方法的路径进行单元测试—稍后你将了解如何通过功能测试来进行此项测试。

如果无法直接对文件系统行为进行单元测试,并且无法测试路由,则有什么要测试的? 嗯,在重构以使单元测试成为可能后,你可能会发现一些测试用例和缺失行为,例如错误处理。 找不到文件时,该方法的作用是什么? 它应该做什么? 在此示例中,重构的方法如下所示:

[HttpGet("[controller]/pic/{id}")]
public IActionResult GetImage(int id)
{
  byte[] imageBytes;
  try
  {
    imageBytes = _imageService.GetImageBytesById(id);
  }
  catch (CatalogImageMissingException ex)
  {
    _logger.LogWarning($"No image found for id: {id}");
    return NotFound();
  }
  return File(imageBytes, "image/png");
}

_logger 并且 _imageService 都作为依赖项注入。 现在,您可以测试传递给操作方法的相同ID是否也传递给_imageService,并确认生成的字节是否作为FileResult的一部分被返回。 还可以测试错误日志记录是否如预期那样进行,同时如果图像缺失,确保返回NotFound结果。假如这种行为是重要的应用程序行为(即不仅仅是开发人员为诊断问题而添加的临时代码),那么这个测试是至关重要的。 实际的文件逻辑已被转移到一个单独的实现服务中,并进行了扩展,以便在文件缺失的情况下返回特定于应用的异常。 可以使用集成测试独立测试此实现。

在大多数情况下,你需要在控制器中使用全局异常处理程序,因此它们中的逻辑量应最小,可能不值得单元测试。 通过使用功能测试和下面所述的TestServer类,对控制器动作进行了大部分测试。

核心应用 ASP.NET 集成测试

ASP.NET Core 应用中的大多数集成测试应是测试基础结构项目中定义的服务和其他实现类型。 例如,可以测试 EF Core 是否已成功更新并检索希望从驻留在基础结构项目中的数据访问类中获得的数据。 测试 ASP.NET Core MVC 项目行为的最佳方法是针对在测试主机中运行的应用运行的功能测试。

核心应用 ASP.NET 功能测试

对于 ASP.NET Core 应用程序,该 TestServer 类使功能测试更易于编写。 你可以直接使用TestServer(或WebHostBuilder)来配置HostBuilder(就像通常为应用程序所做的那样),或者使用WebApplicationFactory类型(自版本 2.1 起可用)。 尽量使测试主机与生产主机尽可能地匹配,以便测试所模拟的行为与应用在生产环境中的行为类似。 该 WebApplicationFactory 类有助于配置 TestServer 的 ContentRoot,ASP.NET Core 使用它来查找静态资源(如视图)。

可以通过创建实现IClassFixture<WebApplicationFactory<TEntryPoint>>的测试类来创建简单的功能测试,其中TEntryPoint是您Web应用程序的Startup类。 通过此接口,测试装置可以使用工厂 CreateClient 的方法创建客户端:

public class BasicWebTests : IClassFixture<WebApplicationFactory<Program>>
{
  protected readonly HttpClient _client;

  public BasicWebTests(WebApplicationFactory<Program> factory)
  {
    _client = factory.CreateClient();
  }

  // write tests that use _client
}

小窍门

如果在 Program.cs 文件中使用最少的 API 配置,则默认情况下,类将声明为内部类,并且无法从测试项目访问。 你可以改为选择 Web 项目中的任何其他实例类,或将此类添加到 Program.cs 文件:

// Make the implicit Program class public so test projects can access it
public partial class Program { }

通常,需要在每次测试运行之前执行站点的其他配置,例如将应用程序配置为使用内存中数据存储,然后使用测试数据对应用程序进行种子设定。 若要实现此功能,请创建自己的子类 WebApplicationFactory<TEntryPoint> 并重写其 ConfigureWebHost 方法。 下面的示例来自 eShopOnWeb FunctionalTests 项目,并用作主 Web 应用程序的测试的一部分。

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.eShopWeb.Infrastructure.Data;
using Microsoft.eShopWeb.Infrastructure.Identity;
using Microsoft.eShopWeb.Web;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;

namespace Microsoft.eShopWeb.FunctionalTests.Web;
public class WebTestFixture : WebApplicationFactory<Startup>
{
  protected override void ConfigureWebHost(IWebHostBuilder builder)
  {
    builder.UseEnvironment("Testing");

    builder.ConfigureServices(services =>
    {
      services.AddEntityFrameworkInMemoryDatabase();

      // Create a new service provider.
      var provider = services
            .AddEntityFrameworkInMemoryDatabase()
            .BuildServiceProvider();

      // Add a database context (ApplicationDbContext) using an in-memory
      // database for testing.
      services.AddDbContext<CatalogContext>(options =>
      {
        options.UseInMemoryDatabase("InMemoryDbForTesting");
        options.UseInternalServiceProvider(provider);
      });

      services.AddDbContext<AppIdentityDbContext>(options =>
      {
        options.UseInMemoryDatabase("Identity");
        options.UseInternalServiceProvider(provider);
      });

      // Build the service provider.
      var sp = services.BuildServiceProvider();

      // Create a scope to obtain a reference to the database
      // context (ApplicationDbContext).
      using (var scope = sp.CreateScope())
      {
        var scopedServices = scope.ServiceProvider;
        var db = scopedServices.GetRequiredService<CatalogContext>();
        var loggerFactory = scopedServices.GetRequiredService<ILoggerFactory>();

        var logger = scopedServices
            .GetRequiredService<ILogger<WebTestFixture>>();

        // Ensure the database is created.
        db.Database.EnsureCreated();

        try
        {
          // Seed the database with test data.
          CatalogContextSeed.SeedAsync(db, loggerFactory).Wait();

          // seed sample user data
          var userManager = scopedServices.GetRequiredService<UserManager<ApplicationUser>>();
          var roleManager = scopedServices.GetRequiredService<RoleManager<IdentityRole>>();
          AppIdentityDbContextSeed.SeedAsync(userManager, roleManager).Wait();
        }
        catch (Exception ex)
        {
          logger.LogError(ex, $"An error occurred seeding the " +
                    "database with test messages. Error: {ex.Message}");
        }
      }
    });
  }
}

测试可以使用此自定义 WebApplicationFactory 来创建客户端,然后使用此客户端实例向应用程序发出请求。 应用程序将具有预置的数据,可以用来作为测试断言的一部分。 以下测试验证 eShopOnWeb 应用程序的主页是否正确加载,并包含作为初始化数据的一部分添加到应用程序中的产品列表。

using Microsoft.eShopWeb.FunctionalTests.Web;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.eShopWeb.FunctionalTests.WebRazorPages;
[Collection("Sequential")]
public class HomePageOnGet : IClassFixture<WebTestFixture>
{
  public HomePageOnGet(WebTestFixture factory)
  {
    Client = factory.CreateClient();
  }

  public HttpClient Client { get; }

  [Fact]
  public async Task ReturnsHomePageWithProductListing()
  {
    // Arrange & Act
    var response = await Client.GetAsync("/");
    response.EnsureSuccessStatusCode();
    var stringResponse = await response.Content.ReadAsStringAsync();

    // Assert
    Assert.Contains(".NET Bot Black Sweatshirt", stringResponse);
  }
}

此功能测试练习完整的 ASP.NET Core MVC/Razor Pages 应用程序堆栈,包括可能已到位的所有中间件、筛选器和绑定器。 它验证给定路由 (“/”) 是否返回预期的成功状态代码和 HTML 输出。 它无需设置真正的 Web 服务器,并避免使用真实 Web 服务器进行测试的很多脆弱(例如防火墙设置问题)。 针对 TestServer 运行的功能测试通常比集成和单元测试慢,但比通过网络运行到测试 Web 服务器的测试要快得多。 使用功能测试来确保应用程序的前端堆栈按预期工作。 当你在控制器或页面中发现重复,并且通过添加筛选器解决重复问题时,这些测试特别有用。 理想情况下,此重构不会更改应用程序的行为,并且一套功能测试将验证这种情况。

参考 – 测试 ASP.NET Core MVC 应用程序