單元測試與整合測試
為什麼要寫測試?
想像你蓋了一棟大樓:
- 沒有測試:住進去才發現水管漏水、電線短路 💥
- 有測試:蓋好一層就檢查一次,問題早早發現 ✅
測試就是你程式碼的品質保證書。
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(假實作)取代不需要驗證的部分。