☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
ASP.NET Core 進階

單元測試與整合測試

為什麼要寫測試?

想像你蓋了一棟大樓:

  • 沒有測試:住進去才發現水管漏水、電線短路 💥
  • 有測試:蓋好一層就檢查一次,問題早早發現 ✅

測試就是你程式碼的品質保證書


xUnit 基礎

// Tests/CalculatorTests.cs
using Xunit;

public class CalculatorTests
{
    // [Fact] → 無參數的單一測試案例
    [Fact]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Arrange(準備)
        var calculator = new Calculator();        // 建立受測物件

        // Act(執行)
        var result = calculator.Add(2, 3);        // 呼叫待測方法

        // Assert(驗證)
        Assert.Equal(5, result);                  // 預期結果是 5
    }

    [Fact]
    public void Add_NegativeNumbers_ReturnsCorrectSum()
    {
        // 準備
        var calculator = new Calculator();        // 建立受測物件

        // 執行
        var result = calculator.Add(-1, -2);      // 加兩個負數

        // 驗證
        Assert.Equal(-3, result);                 // 預期結果是 -3
    }

    // [Theory] + [InlineData] → 多組參數的參數化測試
    [Theory]
    [InlineData(1, 1, 2)]                         // 第一組測試資料
    [InlineData(0, 0, 0)]                         // 第二組測試資料
    [InlineData(-1, 1, 0)]                        // 第三組測試資料
    [InlineData(100, 200, 300)]                   // 第四組測試資料
    public void Add_VariousInputs_ReturnsExpected(
        int a, int b, int expected)
    {
        var calculator = new Calculator();        // 準備
        var result = calculator.Add(a, b);        // 執行
        Assert.Equal(expected, result);           // 驗證
    }
}

Arrange-Act-Assert 模式

// 每個測試都遵循 AAA 模式
[Fact]
public void GetDiscountedPrice_VipCustomer_Returns20PercentOff()
{
    // ═══ Arrange(準備)═══
    var service = new PricingService();           // 建立受測服務
    var product = new Product                     // 建立測試商品
    {
        Name = "筆電",                            // 商品名稱
        Price = 10000                              // 原價一萬
    };
    var customer = new Customer                   // 建立 VIP 客戶
    {
        IsVip = true                               // VIP 身份
    };

    // ═══ Act(執行)═══
    var result = service.GetDiscountedPrice(
        product, customer);                        // 計算折扣價

    // ═══ Assert(驗證)═══
    Assert.Equal(8000, result);                    // VIP 打八折 = 8000
}

常用 Assert 方法

// 常用的斷言方法
Assert.Equal(expected, actual);                    // 值相等
Assert.NotEqual(unexpected, actual);               // 值不相等
Assert.True(condition);                            // 條件為真
Assert.False(condition);                           // 條件為假
Assert.Null(obj);                                  // 物件為 null
Assert.NotNull(obj);                               // 物件不為 null
Assert.Contains("子字串", fullString);              // 包含子字串
Assert.Empty(collection);                          // 集合為空
Assert.Throws<ArgumentException>(                  // 預期丟出例外
    () => service.DoSomething(null));
Assert.IsType<Product>(result);                    // 型別檢查

Moq 模擬框架

// 用 Moq 模擬依賴
using Moq;

[Fact]
public void GetProduct_ExistingId_ReturnsProduct()
{
    // Arrange - 建立 Mock 物件
    var mockRepo = new Mock<IProductRepository>();  // 模擬 Repository

    // Setup - 設定模擬行為
    mockRepo.Setup(r => r.GetById(1))              // 當呼叫 GetById(1) 時
        .Returns(new Product                        // 回傳假資料
        {
            Id = 1,                                 // 商品 ID
            Name = "測試商品",                       // 商品名稱
            Price = 100                              // 商品價格
        });

    var service = new ProductService(
        mockRepo.Object);                           // 注入 Mock 物件

    // Act
    var result = service.GetProduct(1);             // 呼叫待測方法

    // Assert
    Assert.NotNull(result);                         // 結果不為 null
    Assert.Equal("測試商品", result!.Name);          // 名稱正確
    Assert.Equal(100, result.Price);                // 價格正確

    // Verify - 驗證方法被呼叫
    mockRepo.Verify(
        r => r.GetById(1),                          // 確認 GetById 被呼叫
        Times.Once());                              // 而且只呼叫一次
}

[Fact]
public void GetProduct_NonExistingId_ReturnsNull()
{
    var mockRepo = new Mock<IProductRepository>();   // 建立 Mock

    mockRepo.Setup(r => r.GetById(999))             // 當查詢不存在的 ID
        .Returns((Product?)null);                    // 回傳 null

    var service = new ProductService(mockRepo.Object); // 注入 Mock

    var result = service.GetProduct(999);            // 查詢不存在的商品

    Assert.Null(result);                             // 結果應為 null
}

整合測試 WebApplicationFactory

// Tests/IntegrationTests/ProductsApiTests.cs
using Microsoft.AspNetCore.Mvc.Testing;

// 整合測試:測試整個 HTTP 管線
public class ProductsApiTests :
    IClassFixture<WebApplicationFactory<Program>>    // 使用測試伺服器
{
    private readonly HttpClient _client;              // HTTP 客戶端

    public ProductsApiTests(
        WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();             // 建立測試用 HttpClient
    }

    [Fact]
    public async Task GetProducts_ReturnsOk()
    {
        // Act - 發送真實 HTTP 請求
        var response = await _client.GetAsync(
            "/api/Products");                         // 呼叫 API

        // Assert - 檢查 HTTP 回應
        response.EnsureSuccessStatusCode();           // 確認 2xx 成功
        Assert.Equal("application/json",
            response.Content.Headers
                .ContentType?.MediaType);              // 確認回傳 JSON
    }

    [Fact]
    public async Task GetProduct_InvalidId_Returns404()
    {
        // Act
        var response = await _client.GetAsync(
            "/api/Products/99999");                   // 查詢不存在的 ID

        // Assert
        Assert.Equal(
            System.Net.HttpStatusCode.NotFound,       // 預期 404
            response.StatusCode);                      // 實際狀態碼
    }

    [Fact]
    public async Task CreateProduct_ValidData_Returns201()
    {
        // Arrange
        var newProduct = new
        {
            Name = "整合測試商品",                      // 測試商品名稱
            Price = 299                                // 測試價格
        };
        var json = JsonSerializer.Serialize(newProduct); // 序列化為 JSON
        var content = new StringContent(
            json, Encoding.UTF8, "application/json");   // 建立請求內容

        // Act
        var response = await _client.PostAsync(
            "/api/Products", content);                   // 發送 POST

        // Assert
        Assert.Equal(
            System.Net.HttpStatusCode.Created,          // 預期 201
            response.StatusCode);                        // 實際狀態碼
    }
}

自訂 WebApplicationFactory

// Tests/CustomWebApplicationFactory.cs
public class CustomWebApplicationFactory :
    WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(
        IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // 移除正式的 DbContext
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType ==
                    typeof(DbContextOptions<AppDbContext>)); // 找到 DbContext 註冊
            if (descriptor != null)
                services.Remove(descriptor);                 // 移除正式版

            // 換成 In-Memory Database
            services.AddDbContext<AppDbContext>(options =>
            {
                options.UseInMemoryDatabase("TestDb");      // 使用記憶體資料庫
            });
        });
    }
}

🤔 我這樣寫為什麼會錯?

❌ 錯誤 1:測試實作細節而不是行為

// ❌ 測試內部實作(檢查私有方法被呼叫幾次)
[Fact]
public void CalculateTotal_ChecksInternalCounter()
{
    var service = new OrderService();                 // 建立服務
    service.CalculateTotal(items);                    // 計算
    Assert.Equal(3, service._internalCounter);        // ❌ 測試內部狀態!
}
// ✅ 測試行為和結果
[Fact]
public void CalculateTotal_ThreeItems_ReturnsCorrectSum()
{
    var service = new OrderService();                 // 建立服務
    var items = new List<OrderItem>                   // 建立測試項目
    {
        new() { Price = 100, Quantity = 2 },          // 200
        new() { Price = 50, Quantity = 1 }            // 50
    };

    var total = service.CalculateTotal(items);        // 計算總價

    Assert.Equal(250, total);                         // ✅ 只關心結果是否正確
}

為什麼? 測試內部實作會讓重構變得困難——一改內部邏輯測試就壞。應該測試「輸入什麼、期望什麼輸出」,這樣重構時只要行為不變,測試就不用改。

❌ 錯誤 2:測試之間互相依賴

// ❌ 測試 B 依賴測試 A 的結果(測試順序不保證!)
private static int _createdId;                       // 共享狀態!

[Fact]
public void Test_A_CreateProduct()
{
    _createdId = _service.Create(dto).Id;            // ❌ 存到靜態變數
    Assert.True(_createdId > 0);
}

[Fact]
public void Test_B_GetProduct()
{
    var product = _service.GetById(_createdId);      // ❌ 依賴 Test_A 的結果
    Assert.NotNull(product);                         // 如果 A 沒先跑,這裡會失敗!
}
// ✅ 每個測試獨立,自己準備資料
[Fact]
public void GetProduct_ExistingId_ReturnsProduct()
{
    // 每個測試自己準備需要的資料
    var created = _service.Create(
        new CreateProductDto { Name = "測試", Price = 100 }); // 自己建立

    var result = _service.GetById(created.Id);       // 用自己建立的 ID 查詢

    Assert.NotNull(result);                          // 驗證結果
    Assert.Equal("測試", result!.Name);              // 不依賴其他測試
}

為什麼? xUnit 不保證測試執行順序,而且可能平行執行。每個測試必須獨立,自己準備(Arrange)需要的資料。

❌ 錯誤 3:Mock 設定太多導致測試脆弱

// ❌ Mock 了太多細節,隨便改一點就壞
[Fact]
public void ProcessOrder_MockEverything()
{
    var mockRepo = new Mock<IOrderRepository>();
    var mockEmail = new Mock<IEmailService>();
    var mockLogger = new Mock<ILogger<OrderService>>();
    var mockCache = new Mock<ICacheService>();
    var mockConfig = new Mock<IConfiguration>();

    // 設定了一堆 Setup...
    mockRepo.Setup(r => r.GetById(It.IsAny<int>())).Returns(new Order());
    mockEmail.Setup(e => e.Send(It.IsAny<string>(), It.IsAny<string>())).Returns(true);
    mockCache.Setup(c => c.Get(It.IsAny<string>())).Returns((string?)null);
    // ... 設定越多越脆弱
}
// ✅ 只 Mock 必要的依賴
[Fact]
public void ProcessOrder_ValidOrder_SendsConfirmationEmail()
{
    // 只 Mock 真正需要驗證的依賴
    var mockEmail = new Mock<IEmailService>();         // 只 Mock 郵件服務
    var service = new OrderService(
        new FakeOrderRepository(),                     // 用 Fake 取代 Mock
        mockEmail.Object);                             // 注入 Mock

    service.ProcessOrder(1);                           // 執行

    // 只驗證我們關心的行為
    mockEmail.Verify(
        e => e.Send(
            It.IsAny<string>(),                        // 任意收件人
            It.Is<string>(s => s.Contains("訂單確認"))), // 郵件包含「訂單確認」
        Times.Once());                                 // 只寄一次
}

為什麼? Mock 太多會讓測試變得脆弱又難維護。每個測試應該只 Mock 與該測試案例相關的依賴,用 Fake(假實作)取代不需要驗證的部分。

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊