ASP.NET Core 6 API 整合測試示範(NUnit)

平時在做軟體開發時應該都會先測試結果是否符合預期,才交付軟體吧?而所做的測試當然也是測越多越好,確保所有功能都正常、沒有被自己改壞,不過這樣的動作很繁瑣,同樣的事情每次都要再測一次,那不如就來寫測試案例讓他自動測吧!

本文要示範的是 API 的整合測試, 首先先產生 ASP.NET Core 的主要專案,使用 API 範本和 .NET 6 。

建立測試 API

在 Program.cs 的最下面加入這行程式碼,用來將 Program 類別暴露給測試專案。
    
app.Run();

public partial class Program { }
    

建立測試用 API,需要傳入 Name 和 Password ,且 Name 為必填、Password 需要至少 3 個字元,等等會測試這些限制是否有正常運作。
    
[ApiController]
public class UserController : ControllerBase
{
    /// <summary>
    /// 新增使用者
    /// </summary>
    [HttpPost("/api/user")]
    public IActionResult Post([FromBody] UserDto userDto)
    {
        return Ok(userDto);
    }

    /// <summary>
    /// 使用者物件
    /// </summary>
    public class UserDto
    {
        [Required] public string Name { get; set; }
        [MinLength(3)] public string Password { get; set; }
    }
}
    

建立測試專案

在同個解決方案中建立一個 NUnit 測試專案,在測試專案中安裝 Microsoft.AspNetCore.Mvc.Testing 套件,
    
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 6.0.16
    
註:套件版本有依據 .NET 版本,在筆者撰文的當下 .NET 6 最高只能安裝到 6.0.16 ,要 .NET 7 才能安裝 7.0.0 或以上的版本。

我們使用 WebApplicationFactory 取得的 HttpClient 來執行測試,建立用於示範的 4 個測試案例:
  • CreateUser_WithValidNameAndPassword_ReturnsSuccess: 符合規範的正常資料
  • CreateUser_WithEmptyName_ReturnsBadRequest: 缺少使用者名稱
  • CreateUser_WithInvalidPassword_ReturnsBadRequest: 密碼過短
  • CreateUser_WithEmptyNameAndInvalidPassword_ReturnsBadRequest: 缺少使用者名稱和密碼過短
在錯誤的案例中使用 System.Text.Json 反序列化取得 API 回應訊息,並確認錯誤訊息是否正確。

完整測試範例:
    
using System.Net;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using JsonSerializer = System.Text.Json.JsonSerializer;

namespace IntegrationTestProject;

public class UserTest
{
    private WebApplicationFactory<Program> _factory = null!;
    private HttpClient _client = null!;

    [OneTimeSetUp]
    public void Init()
    {
        _factory = new WebApplicationFactory<Program>();
        _client = _factory.CreateClient();
    }

    [OneTimeTearDown]
    public void Cleanup()
    {
        _factory.Dispose();
    }

    /// <summary>
    /// 驗證錯誤的訊息物件
    /// </summary>
    public class ValidationErrors
    {
        [JsonPropertyName("errors")] public Dictionary<string, List<string>> Errors { get; set; }
    }

    [Test]
    public async Task CreateUser_WithValidNameAndPassword_ReturnsSuccess()
    {
        // Arrange
        var user = new { name = "John", password = "password123" };
        var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");

        // Act
        var response = await _client.PostAsync("/api/user", content);

        // Assert
        response.EnsureSuccessStatusCode();
    }

    [Test]
    public async Task CreateUser_WithEmptyName_ReturnsBadRequest()
    {
        // Arrange
        var user = new { name = "", password = "password123" };
        var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");

        // Act
        var response = await _client.PostAsync("/api/user", content);
        var errorObject = JsonConvert.DeserializeObject<ValidationErrors>(await response.Content.ReadAsStringAsync());

        // Assert
        Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.IsNotNull(errorObject);
        Assert.AreEqual(1, errorObject.Errors.Count);
        Assert.AreEqual("Name", errorObject.Errors.First().Key);
        Assert.AreEqual("The Name field is required.",
            errorObject.Errors["Name"][0]);
    }

    [Test]
    public async Task CreateUser_WithInvalidPassword_ReturnsBadRequest()
    {
        // Arrange
        var user = new { name = "John", password = "pw" };
        var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");

        // Act
        var response = await _client.PostAsync("/api/user", content);
        var errorObject = JsonConvert.DeserializeObject<ValidationErrors>(await response.Content.ReadAsStringAsync());

        // Assert
        Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.IsNotNull(errorObject);
        Assert.AreEqual(1, errorObject.Errors.Count);
        Assert.AreEqual("Password", errorObject.Errors.First().Key);
        Assert.AreEqual("The field Password must be a string or array type with a minimum length of '3'.",
            errorObject.Errors["Password"][0]);
    }

    [Test]
    public async Task CreateUser_WithEmptyNameAndInvalidPassword_ReturnsBadRequest()
    {
        // Arrange
        var user = new { name = "", password = "pw" };
        var content = new StringContent(JsonSerializer.Serialize(user), Encoding.UTF8, "application/json");

        // Act
        var response = await _client.PostAsync("/api/user", content);
        var errorObject = JsonConvert.DeserializeObject<ValidationErrors>(await response.Content.ReadAsStringAsync());

        // Assert
        Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.IsNotNull(errorObject);
        Assert.AreEqual(2, errorObject.Errors.Count);
        Assert.AreEqual(
            "The field Password must be a string or array type with a minimum length of '3'.",
            errorObject.Errors["Password"][0]
        );
        Assert.AreEqual("The Name field is required.",
            errorObject.Errors["Name"][0]);
    }
}
    


常見錯誤: Program 需要 using 的是主要專案,而不是 Microsoft.VisualStudio.TestPlatform.TestHost ,如果錯用了會出現類似下面的錯誤訊息:
    
System.InvalidOperationException : Can't find 'C:\Users\ruyut\Documents\RiderProjects\2023\test\WebApplicationIntegrationTest\IntegrationTestProject\bin\Debug\net6.0\testhost.deps.json'. This file is required for functional tests to run properly. There should be a copy of the file on your source project bin folder. If that is not the case, make sure that the property PreserveCompilationContext is set to true on your project file. E.g '<PreserveCompilationContext>true</PreserveCompilationContext>'. For functional tests to work they need to either run from the build output folder or the testhost.deps.json file from your application's output directory must be copied to the folder where the tests are running on. A common cause for this error is having shadow copying enabled when the tests run.
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.EnsureDepsFile()
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.EnsureServer()
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.CreateDefaultClient(DelegatingHandler[] handlers)
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.CreateDefaultClient(Uri baseAddress, DelegatingHandler[] handlers)
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.CreateClient(WebApplicationFactoryClientOptions options)
   at Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1.CreateClient()
   at IntegrationTestProject.UserTest.Init() in C:\Users\ruyut\Documents\RiderProjects\2023\test\WebApplicationIntegrationTest\IntegrationTestProject\UnitTest1.cs:line 20
    

發生此錯誤請確認 Program.cs 有確實加上 public partial class Program { } ,並且在 WebApplicationFactory 中的 Program 是指向 Program.cs

延伸閱讀: 在 C# 單元測試中使用 Fluent Assertions ,用更容易閱讀的方式寫測試

參考資料:
Microsfot.Learn - Integration tests in ASP.NET Core
NUnit - OneTimeSetUp

留言