共用方式為


測試 ASP.NET Core MVC 應用程式

小提示

此內容摘自電子書《使用 ASP.NET Core 和 Azure 架構現代化 Web 應用程式》,可在 .NET Docs 上取得,或下載免費 PDF 以便離線閱讀。

ASP.NET Core 與 Azure 架構現代 Web 應用程式的電子書封面縮圖。

「如果您不喜歡對產品進行單元測試,最有可能的客戶也不喜歡測試產品。 _-匿名-

任何複雜度的軟體可能會以非預期的方式失敗,以響應變更。 因此,除了最簡單(或最不重要的)應用程式,還需要在進行變更之後進行測試。 手動測試是測試軟體最慢、最不可靠、最昂貴的方式。 不幸的是,如果應用程式不是設計為可測試,它可以是唯一可用的測試方法。 撰寫以遵循 第 4 章 中所述架構原則的應用程式,基本上應可進行單元測試。 ASP.NET Core 應用程式支援自動化整合和功能測試。

自動化測試種類

軟體應用程式有許多種自動化測試。 最簡單的最低層級測試是單元測試。 在稍微較高的層級,有整合測試和功能測試。 其他種類的測試,例如UI測試、負載測試、壓力測試和煙霧測試,都超出本檔的範圍。

單元測試

單元測試會測試應用程式邏輯的單一部分。 可以進一步描述它,方法之一是列出一些它不是的事情。 單元測試不會測試程序代碼如何與相依性或基礎結構搭配運作, 這就是整合測試的用途。 單元測試不會測試撰寫程式代碼的架構– 您應該假設其運作正常,或者,如果您發現它沒有,請提出 Bug 並撰寫因應措施的程式碼。 單元測試完全在記憶體中運行並在程序內執行。 它不會與文件系統、網路或資料庫通訊。 單元測試應該只測試您的程序代碼。

單元測試,因為其只測試您程式碼的單一單元,而沒有外部相依性,應該執行得非常快。 因此,您應該能夠在幾秒鐘內執行數百個單元測試的測試套件。 理想情況下,應在每次推送至共用原始檔控制存放庫之前頻繁執行它們,並且在您的組建伺服器上進行每次自動化組建時執行。

整合測試

雖然封裝與資料庫和文件系統等基礎結構互動的程式代碼是個好主意,但您仍然會有其中一些程序代碼,而且您可能想要測試它。 此外,您應該在應用程式的相依性被完全解決時,確認程式代碼的層能如預期般互動。 負責這項功能的是整合測試。 整合測試通常比單元測試更慢且更難設定,因為它們通常相依於外部相依性和基礎結構。 因此,您應該避免在整合測試中測試那些可以用單元測試進行測試的事物。 如果可以使用單元測試來測試指定的案例,您應該使用單元測試來測試它。 如果您無法,請考慮使用整合測試。

整合測試通常會有比單元測試更複雜的設定和卸除程式。 例如,針對實際資料庫的整合測試需要在每次測試執行之前,將資料庫傳回已知狀態。 隨著新測試的新增和生產資料庫架構的發展,這些測試腳本通常會隨著大小和複雜度而成長。 在許多大型系統中,在檢查共用原始檔控制變更之前,在開發人員工作站上執行完整的整合測試套件是不切實際的。 在這些情況下,整合測試可能會在組建伺服器上執行。

功能測試

整合測試是從開發人員的觀點撰寫,以確認系統的某些元件能正常運作。 功能測試是從用戶的觀點撰寫,並根據系統需求來驗證系統的正確性。 下列摘錄提供與單元測試相比,如何思考功能測試的實用類比:

“很多時候,系統的發展被比作房子的建築。 雖然這個類比並不完全正確,但為了瞭解單元和功能測試之間的差異,我們可以擴充它。 單元測試類似於參觀房屋建築工地的建築檢查員。 他專注於房子的各種內部系統,基礎,框架,電氣,管道等。 他確保(測試)房屋的各個部分能夠正確和安全地運作,以符合建築法規。 在此案例中,功能性測試就如同房屋主人探訪同一建築工地。 他假設內部系統的行為會適當,建築檢查員正在執行他的任務。 房主專注於住在這所房子時的生活將會是什麼樣子。 他關心房子的外觀、房間的大小是否令人舒適、房子是否符合家庭的需求,以及窗戶是否位於良好的位置以迎接早晨的陽光。 房主正在對房子進行功能測試。 他具有使用者的觀點。 建築檢查員正在對房子進行單元測試。 他有建築商的觀點。

來源: 單元測試與功能測試

我喜歡說:「作為開發人員,我們以兩種方式失敗:我們建置錯誤的東西,或者我們建置錯誤的東西。單元測試可確保您正在建置正確的專案;功能測試可確保您正在建置正確的專案。

由於功能測試會在系統層級運作,因此可能需要某種程度的UI自動化。 和整合測試一樣,它們通常也會與某種測試基礎結構搭配使用。 此活動比單元和整合測試更慢且更脆弱。 您應該進行足夠的功能測試,以確保系統行為符合使用者的期望。

測試金字塔

Martin Fowler 撰寫了測試金字塔,其中範例顯示在圖 9-1 中。

測試金字塔

圖 9-1。 測試金字塔

金字塔的不同層及其相對大小,代表不同類型的測試,以及您應該為應用程式撰寫多少。 如您所見,建議有大量的單元測試基礎,由較小的整合測試層所支援,且功能測試層更小。 在理想情況下,每一層應該只包含那些在較低層無法有效執行的測試。 當您嘗試決定特定案例所需的測試類型時,請記住測試金字塔。

要測試的專案

對於不熟悉撰寫自動化測試的開發人員來說,常見的問題是如何決定要測試什麼內容。 良好的起點是測試條件式邏輯。 任何地方您都有一個方法,其行為會根據條件語句變更(if-else、switch 等等),您應該至少能夠想出一些測試,以確認特定條件的正確行為。 如果您的程式代碼有錯誤狀況,最好透過程式代碼撰寫至少一個「快樂路徑」測試(不含錯誤),以及至少一個「悲傷路徑」測試(錯誤或非典型結果),以確認您的應用程式在面對錯誤時的行為如預期般運作。 最後,請嘗試專注於測試可能會失敗的專案,而不是專注於程式代碼涵蓋範圍之類的計量。 一般而言,程式代碼涵蓋範圍比更少更好。 不過,多撰寫一些複雜且對業務至關重要的方法的測試,通常比撰寫自動屬性測試只是為了提升測試代碼的覆蓋率更值得投入時間。

組織測試專案

您可以按照最適合您的方式組織測試專案。 最好依類型來分隔測試(單元測試、整合測試),以及它們所測試的專案(依專案、依命名空間)。 不論此區隔是由單一測試專案或多個測試專案內的資料夾所組成,都是設計決策。 一個專案最簡單,但對於具有許多測試的大型專案,或為了更輕鬆地執行不同的測試集,您可能想要有數個不同的測試專案。 許多小組會根據他們正在測試的專案來組織測試專案,對於具有多個專案的應用程式,可能會導致大量的測試專案,特別是如果您仍然根據每個專案中的測試類型細分這些專案。 折衷方法是讓每種測試、每個應用程式各有一個專案,其中含有測試專案內的資料夾,以顯示正在測試的專案(和類別)。

常見的方法是將應用程式項目組織在 『src』 資料夾下,以及平行 『tests' 資料夾下的應用程式測試專案。 如果您發現此組織很有用,您可以在 Visual Studio 中建立相符的解決方案資料夾。

在您的解決方案中組織測試

圖 9-2。 在您的解決方案中測試組織

您可以使用您偏好的測試架構。 xUnit 架構運作良好,而且是撰寫所有 ASP.NET Core 和 EF Core 測試的內容。 您可以使用 Visual Studio 中圖 9-3 所示的範本,或使用 CLI dotnet new xunit新增 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 Core 應用程式單元測試

在設計完善的 ASP.NET Core 應用程式中,大部分的複雜度和商業規則都會封裝在商務實體和各種服務中。 ASP.NET Core MVC 應用程式本身及其控制器、篩選條件、檢視模型和檢視,應該需要少量的單元測試。 指定動作的大部分功能都位於動作方法本身之外。 測試路由或全域錯誤處理是否正常運作,無法有效地進行單元測試。 同樣地,任何篩選,包括模型驗證和驗證和授權篩選,都不能使用以控制器動作方法為目標的測試進行單元測試。 如果沒有這些行為來源,大部分的動作方法應該極其簡單,將大部分的工作委派給可在不依賴使用者控制器的情況下獨立運作的服務。

有時候您必須重構程序代碼,才能進行單元測試。 此活動通常涉及識別抽象概念,並使用相依性插入來存取您想要測試的程式代碼中的抽象概念,而不是直接針對基礎結構撰寫程序代碼。 例如,請考慮這個簡單的動作方法來顯示影像:

[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 都會插入為相依性。 現在,您可以測試傳遞至動作方法和_imageService的相同標識符,並確認產生的位元組作為FileResult的一部分被返回。 您也可以測試錯誤記錄是否如預期般發生,如果 NotFound 影像遺失,則會傳回結果,假設此行為是重要的應用程式行為(也就是說,不只是開發人員新增來診斷問題的暫存程式代碼)。 實際的檔案邏輯已移至個別的執行服務,並已強化以在遺漏檔案案例時傳回應用程式特定的異常。 您可以使用整合測試,獨立測試此實作。

在大部分情況下,您會想要在控制器中使用全域例外狀況處理程式,因此它們中的邏輯數量應該最少,而且可能不值得單元測試。 使用功能測試和以下所述的TestServer類別,進行大部分控制器動作的測試。

ASP.NET Core 應用程式的整合測試

ASP.NET Core 應用程式中的大部分整合測試都應該是測試基礎結構專案中定義的服務和其他實作類型。 例如,您可以測試 EF Core 是否已成功更新並從基礎結構專案中的資料存取類別中擷取您所期望的數據。 測試您的 ASP.NET Core MVC 專案運作方式的最佳方式,是針對在測試主機中執行的應用程式執行的功能測試。

核心應用程式 ASP.NET 功能測試

針對 ASP.NET Core 應用程式,類別 TestServer 可讓功能測試變得相當容易撰寫。 您可以直接使用 TestServer (或WebHostBuilder) 來設定 HostBuilder ,就像您通常為應用程式所做的一樣),或使用 WebApplicationFactory 類型(自 2.1 版起提供)。 請嘗試盡可能將測試主機與生產主機相符,因此您的測試練習行為與應用程式在生產環境中執行的動作類似。 類別 WebApplicationFactory 有助於設定 TestServer 的 ContentRoot,而 TestServer 的 ContentRoot 是由 ASP.NET Core 用來尋找靜態資源,例如 Views。

您可以建立實作 IClassFixture<WebApplicationFactory<TEntryPoint>>的測試類別,其中 TEntryPoint 是 Web 應用程式的 Startup 類別,以建立簡單的功能測試。 有了這個介面,您的測試裝置就可以使用 Factory 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 輸出。 它不需要設定真正的網頁伺服器,並避免使用真實網頁伺服器進行測試的脆弱程度(例如防火牆設定的問題)。 針對 TestServer 執行的功能測試通常比整合和單元測試慢,但比透過網路對測試 Web 伺服器執行的測試要快得多。 使用功能測試來確保應用程式的前端堆疊如預期般運作。 當您在控制器或頁面中發現重複,並藉由新增篩選來解決重複問題時,這些測試特別有用。 在理想情況下,此重構不會變更應用程式的行為,而且一套功能測試會確認這種情況。

參考資料 – 測試 ASP.NET Core MVC 應用程式