前面我們學了很多單元測試的技巧,從基本的 xUnit 框架到各種 Mock 工具,再到處理時間、檔案系統等相依性問題。今天我們要跨出單元測試的範圍,進入整合測試的領域。
整合測試在軟體開發中扮演關鍵角色,特別是現代微服務架構和 Web API 開發。單元測試很好,但有些問題只有在多個元件一起運作時才會浮現:
今天我們要學習如何在 ASP.NET Core 中建立有效的整合測試,掌握 WebApplicationFactory
的使用,並實作第一個 Web API 整合測試。
根據測試理論,整合測試有兩個重要定義:
定義一:多物件協作測試
將兩個以上的類別做整合,並且測試它們之間的運作是不是正確的,測試案例一定是跨類別物件的,通常是會用到多個物件來做
定義二:外部資源整合測試
會使用到外部資源,例如資料庫、外部服務、檔案、需要對測試環境進行特別處理等等
整合測試是測試多個元件組合在一起時是否能正常運作的測試方式。與單元測試不同,整合測試會實際啟動應用程式的某些部分,讓各個元件真實地互動。
為什麼需要整合測試?
雖然每個單元模組都通過各自的單元測試,但是組合後的各個模組未必能順利執行(單元測試無法做到這一點)。整合測試的目的是:
用一個 Web API 的例子來說明三種測試的差異:
[ApiController]
[Route("api/[controller]")]
public class ShippersController : ControllerBase
{
private readonly IShipperService _shipperService;
private readonly ILogger<ShippersController> _logger;
public ShippersController(IShipperService shipperService, ILogger<ShippersController> logger)
{
_shipperService = shipperService;
_logger = logger;
}
[HttpGet("{id}")]
public async Task<ActionResult<SuccessResultOutputModel<ShipperOutputModel>>> GetShipper(int id)
{
_logger.LogInformation("取得貨運商資料:{ShipperId}", id);
var exists = await _shipperService.IsExistsAsync(id);
if (!exists)
{
return NotFound();
}
var shipper = await _shipperService.GetAsync(id);
var result = new SuccessResultOutputModel<ShipperOutputModel>
{
Status = "Success",
Data = new ShipperOutputModel
{
ShipperId = shipper.ShipperId,
CompanyName = shipper.CompanyName,
Phone = shipper.Phone
}
};
return Ok(result);
}
[HttpPost]
public async Task<ActionResult<SuccessResultOutputModel<ShipperOutputModel>>> CreateShipper(
ShipperCreateParameter parameter)
{
_logger.LogInformation("建立新貨運商:{CompanyName}", parameter.CompanyName);
var shipper = await _shipperService.CreateAsync(parameter);
var result = new SuccessResultOutputModel<ShipperOutputModel>
{
Status = "Success",
Data = new ShipperOutputModel
{
ShipperId = shipper.ShipperId,
CompanyName = shipper.CompanyName,
Phone = shipper.Phone
}
};
return CreatedAtAction(nameof(GetShipper), new { id = shipper.ShipperId }, result);
}
}
單元測試的做法:
[Fact]
public async Task GetShipper_貨運商存在_應回傳貨運商資料()
{
// Arrange
var mockShipperService = Substitute.For<IShipperService>();
var mockLogger = Substitute.For<ILogger<ShippersController>>();
var shipperId = 1;
var shipperEntity = new Shipper { ShipperId = 1, CompanyName = "測試公司", Phone = "0912345678" };
mockShipperService.IsExistsAsync(shipperId).Returns(true);
mockShipperService.GetAsync(shipperId).Returns(shipperEntity);
var controller = new ShippersController(mockShipperService, mockLogger);
// Act
var result = await controller.GetShipper(shipperId);
// Assert
var okResult = result.Result.Should().BeOfType<OkObjectResult>();
var model = okResult.Subject.Value.Should().BeOfType<SuccessResultOutputModel<ShipperOutputModel>>();
model.Subject.Status.Should().Be("Success");
model.Subject.Data.CompanyName.Should().Be("測試公司");
}
整合測試的做法:
[Fact]
public async Task GetShipper_貨運商存在_應回傳貨運商資料()
{
// Arrange
// 在真實資料庫中準備測試資料
var testData = new { CompanyName = "測試公司", Phone = "0912345678" };
DatabaseCommand.ExecuteSqlCommand(ConnectionString, GetInsertShipperCommand(), testData);
using var client = _factory.CreateClient();
// Act
// 發送真實的 HTTP 請求
var response = await client.GetAsync("/api/shippers/1");
// Assert
// 驗證真實的 HTTP 回應
response.Should().Be200Ok()
.And.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(model =>
{
model.Status.Should().Be("Success");
model.Data.CompanyName.Should().Be("測試公司");
});
}
整合測試在測試金字塔中位於單元測試和端對端測試之間。
按照測試金字塔的理論,我們應該有大量的單元測試(基礎),適量的整合測試(中層),以及少量的端對端測試(頂層)。
各種測試的特性比較:
測試類型 | 測試範圍 | 執行速度 | 維護成本 | 建議比例 | 發現問題的層級 |
---|---|---|---|---|---|
單元測試 | 單一類別/方法 | 很快 | 低 | 70% | 邏輯錯誤 |
整合測試 | 多個元件 | 中等 | 中等 | 20% | 介面整合問題 |
端對端測試 | 完整流程 | 慢 | 高 | 10% | 使用者體驗問題 |
整合測試和端對端測試常被混淆,我們來釐清差異:
整合測試:
端對端測試:
優點:
缺點:
使用時機:
WebApplication 的整合測試有必要做嗎?
這是一個實務上的重要問題。答案是肯定的:
專案如果只有做 Service 層的單元測試還不夠
就算有做 WebApplication 的 Controller 單元測試也仍然不夠
WebApplication 做了太多的整合與設定,單元測試是無法確認到全部
例如:Routing 路由設定、Controller 與 ActionFilter、Middleware 整個 Request Response Pipeline 的整合運作處理等等
用嚴謹的角度來看,專案的 WebApplication 整合測試是有其必要
但是要怎麼做呢?因為看了一堆文件、網路文章,實際要做還是遇到一堆問題
注意事項:
ASP.NET Core 提供了專門的測試套件 Microsoft.AspNetCore.Mvc.Testing
,這個套件包含了進行整合測試所需的核心工具。
主要元件:
安裝必要套件:
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<PackageReference Include="AwesomeAssertions.Web" Version="1.9.6" />
<PackageReference Include="System.Net.Http.Json" Version="9.0.8" />
重要的套件說明:
AwesomeAssertions.Web:透過 AwesomeAssertions.Web 可以簡化對於 response 輸出內容的驗證
System.Net.Http.Json:.NET 內建的 JSON HTTP 擴展套件,提供簡潔的 JSON 序列化和反序列化方法,是現代 .NET 應用程式處理 JSON 的首選
System.Net.Http.Json 的現代化優勢:
// 傳統方式:需要手動序列化和建立 StringContent
var createParameter = new ShipperCreateParameter { CompanyName = "測試公司", Phone = "02-1234-5678" };
var jsonContent = JsonSerializer.Serialize(createParameter);
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/shippers", content);
// 現代化方式:使用 PostAsJsonAsync 一行搞定
var createParameter = new ShipperCreateParameter { CompanyName = "測試公司", Phone = "02-1234-5678" };
var response = await client.PostAsJsonAsync("/api/shippers", createParameter);
// 回應讀取也更簡潔
// 傳統方式
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<SuccessResultOutputModel<ShipperOutputModel>>(responseContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
// 現代化方式
var result = await response.Content.ReadFromJsonAsync<SuccessResultOutputModel<ShipperOutputModel>>();
主要優勢:
PostAsJsonAsync
比手動序列化 + StringContent
更簡潔application/json
System.Net.Http.Json 的優勢:
// 傳統的驗證方式(冗長且容易出錯)
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
var data = JsonSerializer.Deserialize<SuccessResultOutputModel<ShipperOutputModel>>(content,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.Equal("測試公司", data.Data.CompanyName);
// 使用 AwesomeAssertions.Web 的現代化驗證方式
response.Should().Be200Ok()
.And.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.CompanyName.Should().Be("測試公司");
});
WebApplicationFactory<T>
是整合測試的核心,它會:
基本使用方式:
public class BasicIntegrationTest : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public BasicIntegrationTest(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task Get_首頁_應回傳成功()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/");
// Assert
response.EnsureSuccessStatusCode(); // 2xx 狀態碼
response.Content.Headers.ContentType?.ToString()
.Should().StartWith("text/html");
}
}
程式碼解析:
IClassFixture<WebApplicationFactory<Program>>
:使用 xUnit 的 ClassFixture 來共享工廠實例WebApplicationFactory<Program>
:泛型參數指向應用程式的入口點CreateClient()
:建立 HttpClient 實例EnsureSuccessStatusCode()
:確保回應是成功狀態碼在進行 HTTP API 測試時,傳統的斷言方式往往冗長且不夠直觀:
// 傳統方式
response.IsSuccessStatusCode.Should().BeTrue();
response.StatusCode.Should().Be(HttpStatusCode.OK);
// 更直觀的方式
response.Should().Be200Ok();
FluentAssertions.Web 是專門為 HTTP 回應測試設計的擴展套件,提供了豐富的斷言方法和詳細的錯誤訊息。
主要特色:
response.Should().Be200Ok(); // HTTP 200
response.Should().Be201Created(); // HTTP 201
response.Should().Be404NotFound(); // HTTP 404
response.Should().Be400BadRequest(); // HTTP 400
response.Should().Be500InternalServerError(); // HTTP 500
// 直接驗證 JSON 回應內容
response.Should().Be200Ok().And.BeAs(new
{
CompanyName = "測試公司",
Phone = "02-1234-5678"
});
// 使用強型別驗證
response.Should().Be200Ok().And.Satisfy<ShipperOutputModel>(model =>
{
model.CompanyName.Should().Be("測試公司");
model.Phone.Should().Be("02-1234-5678");
});
.Satisfy<T>()
是 FluentAssertions.Web 最強大的功能之一,它能自動將 HTTP 回應內容反序列化為指定型別,然後讓我們對整個物件進行詳細驗證。
完整的成功回應驗證:
[Fact]
public async Task GetShipper_當貨運商存在_應回傳成功結果()
{
// Arrange
await CleanupDatabaseAsync();
var shipperId = await SeedShipperAsync("順豐速運", "02-2345-6789");
// Act
HttpResponseMessage response = await Client.GetAsync($"/api/shippers/{shipperId}");
// Assert
response.Should().Be200Ok()
.And
.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.ShipperId.Should().Be(shipperId);
result.Data.CompanyName.Should().Be("順豐速運");
result.Data.Phone.Should().Be("02-2345-6789");
});
}
建立資料的驗證:
[Fact]
public async Task CreateShipper_輸入有效資料_應建立成功()
{
// Arrange
await CleanupDatabaseAsync();
var createParameter = new ShipperCreateParameter
{
CompanyName = "黑貓宅急便",
Phone = "02-1234-5678"
};
// Act - 使用 PostAsJsonAsync 簡化 JSON 序列化
HttpResponseMessage response = await Client.PostAsJsonAsync("/api/shippers", createParameter);
// Assert
response.Should().Be201Created()
.And
.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.ShipperId.Should().BeGreaterThan(0);
result.Data.CompanyName.Should().Be("黑貓宅急便");
result.Data.Phone.Should().Be("02-1234-5678");
});
}
錯誤回應的詳細驗證:
[Fact]
public async Task CreateShipper_當公司名稱為空_應回傳400BadRequest()
{
// Arrange
await CleanupDatabaseAsync();
var createParameter = new ShipperCreateParameter
{
CompanyName = "", // 空字串
Phone = "02-1234-5678"
};
// Act - 使用 PostAsJsonAsync 取代手動序列化
var response = await Client.PostAsJsonAsync("/api/shippers", createParameter);
// Assert
response.Should().Be400BadRequest()
.And
.Satisfy<ValidationProblemDetails>(problem =>
{
problem.Type.Should().Be("https://tools.ietf.org/html/rfc9110#section-15.5.1");
problem.Title.Should().Be("One or more validation errors occurred.");
problem.Status.Should().Be(400);
problem.Errors.Should().ContainKey("CompanyName");
problem.Errors["CompanyName"].Should().Contain("公司名稱為必填");
});
}
集合資料的驗證:
[Fact]
public async Task GetAllShippers_應回傳所有貨運商()
{
// Arrange
await CleanupDatabaseAsync();
await SeedShipperAsync("公司A", "02-1111-1111");
await SeedShipperAsync("公司B", "02-2222-2222");
// Act
var response = await Client.GetAsync("/api/shippers");
// Assert
response.Should().Be200Ok()
.And
.Satisfy<SuccessResultOutputModel<List<ShipperOutputModel>>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.Count.Should().Be(2);
result.Data.Should().Contain(s => s.CompanyName == "公司A");
result.Data.Should().Contain(s => s.CompanyName == "公司B");
});
}
JsonSerializer.Deserialize
.And
讓多個驗證條件串聯在一起與傳統方式的比較:
// 傳統方式 - 冗長且容易出錯
response.IsSuccessStatusCode.Should().BeTrue();
var content = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<SuccessResultOutputModel<ShipperOutputModel>>(content,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
result.Should().NotBeNull();
result!.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.CompanyName.Should().Be("測試公司");
// 使用 Satisfy<T> - 簡潔且直觀
response.Should().Be200Ok()
.And
.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.CompanyName.Should().Be("測試公司");
});
安裝套件:
dotnet add package FluentAssertions.Web --version 1.2.0
專案檔案參考:
<PackageReference Include="FluentAssertions.Web" Version="1.2.0" />
完整的使用範例:
[Fact]
public async Task GetShipper_貨運商存在_應回傳正確資料()
{
// Arrange
using var client = _factory.CreateClient();
var shipperId = await SeedTestDataAsync();
// Act
var response = await client.GetAsync($"/api/shippers/{shipperId}");
// Assert
response.Should().Be200Ok()
.And.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.CompanyName.Should().Be("測試公司");
result.Data.Phone.Should().Be("02-1234-5678");
});
}
在使用 FluentAssertions.Web 時,最常遇到的問題是套件版本不相容導致的編譯錯誤:
error CS1061: 'ObjectAssertions' 未包含 'Be200Ok' 的定義
問題原因:
FluentAssertions.Web 實際上有三個不同的套件版本,需要根據你使用的基礎斷言庫來選擇:
基礎斷言庫 | 正確的套件 | NuGet 套件連結 |
---|---|---|
FluentAssertions < 8.0.0 | FluentAssertions.Web | FluentAssertions.Web |
FluentAssertions >= 8.0.0 | FluentAssertions.Web.v8 | FluentAssertions.Web.v8 |
AwesomeAssertions >= 8.0.0 | AwesomeAssertions.Web | AwesomeAssertions.Web |
解決方案步驟:
<!-- 如果專案檔案中有這個 -->
<PackageReference Include="AwesomeAssertions" Version="9.1.0" />
<!-- 錯誤:使用 AwesomeAssertions 但安裝 FluentAssertions.Web -->
<PackageReference Include="FluentAssertions.Web" Version="1.2.0" />
<!-- 正確:使用 AwesomeAssertions 應該安裝 AwesomeAssertions.Web -->
<PackageReference Include="AwesomeAssertions.Web" Version="1.9.6" />
// 錯誤的 using
global using FluentAssertions.Web;
// 正確的 using
global using AwesomeAssertions.Web;
驗證修正:
修正後,HTTP 斷言應該能正常工作:
// 這些方法應該都能正常使用
response.Should().Be200Ok();
response.Should().Be201Created();
response.Should().Be404NotFound();
response.Should().Be400BadRequest();
重要提醒:
這個問題很常見但很容易忽略,正確的套件選擇是成功使用 AwesomeAssertions.Web
的關鍵。
TestServer 是一個特殊的 ASP.NET Core 伺服器實作,它:
TestServer 的優勢:
// 傳統的整合測試(需要真實伺服器)
var httpClient = new HttpClient();
var response = await httpClient.GetAsync("http://localhost:5000/api/health");
// 使用 TestServer 的整合測試
var client = _factory.CreateClient();
var response = await client.GetAsync("/api/health"); // 不需要端口
整合測試通常需要特殊的環境配置:
自訂 WebApplicationFactory:
public class CustomWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram>
where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 移除原本的資料庫設定
services.RemoveAll(typeof(DbContextOptions<AppDbContext>));
// 加入記憶體資料庫
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase");
});
// 替換外部服務為測試版本
services.Replace(ServiceDescriptor.Scoped<IEmailService, TestEmailService>());
});
// 設定測試環境
builder.UseEnvironment("Testing");
// 覆寫設定
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("Logging:LogLevel:Default", "Warning"),
new KeyValuePair<string, string>("ConnectionStrings:TestDb", "InMemory")
});
});
}
}
GET 請求測試:
[Fact]
public async Task GetShippers_無參數_應回傳所有貨運商清單()
{
// Arrange
using var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/shippers");
// Assert
response.Should().Be200Ok();
response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
}
[Fact]
public async Task GetShipper_當貨運商存在_應回傳成功結果()
{
// Arrange
await CleanupDatabaseAsync();
var shipperId = await SeedShipperAsync("順豐速運", "02-2345-6789");
// Act
var response = await Client.GetAsync($"/api/shippers/{shipperId}");
// Assert
response.Should().Be200Ok()
.And
.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.ShipperId.Should().Be(shipperId);
result.Data.CompanyName.Should().Be("順豐速運");
result.Data.Phone.Should().Be("02-2345-6789");
});
}
POST 請求測試:
[Fact]
public async Task CreateShipper_輸入有效資料_應建立成功()
{
// Arrange
await CleanupDatabaseAsync();
var createParameter = new ShipperCreateParameter
{
CompanyName = "黑貓宅急便",
Phone = "02-1234-5678"
};
// Act - 使用 PostAsJsonAsync 簡化操作
var response = await Client.PostAsJsonAsync("/api/shippers", createParameter);
// Assert
response.Should().Be201Created()
.And
.Satisfy<SuccessResultOutputModel<ShipperOutputModel>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.ShipperId.Should().BeGreaterThan(0);
result.Data.CompanyName.Should().Be("黑貓宅急便");
result.Data.Phone.Should().Be("02-1234-5678");
});
}
JSON 內容的反序列化驗證:
[Fact]
public async Task GetAllShippers_應回傳所有貨運商()
{
// Arrange
await CleanupDatabaseAsync();
await SeedShipperAsync("公司A", "02-1111-1111");
await SeedShipperAsync("公司B", "02-2222-2222");
// Act
var response = await Client.GetAsync("/api/shippers");
// Assert
response.Should().Be200Ok()
.And
.Satisfy<SuccessResultOutputModel<List<ShipperOutputModel>>>(result =>
{
result.Status.Should().Be("Success");
result.Data.Should().NotBeNull();
result.Data!.Count.Should().Be(2);
result.Data.Should().Contain(s => s.CompanyName == "公司A");
result.Data.Should().Contain(s => s.CompanyName == "公司B");
});
}
錯誤情況的測試:
[Fact]
public async Task GetShipper_不存在的ID_應回傳404NotFound()
{
// Arrange
using var client = _factory.CreateClient();
var nonExistentShipperId = 9999;
// Act
var response = await client.GetAsync($"/api/shippers/{nonExistentShipperId}");
// Assert
response.Should().Be404NotFound();
}
[Fact]
public async Task CreateShipper_無效的資料_應回傳400BadRequest()
{
// Arrange
using var client = _factory.CreateClient();
var invalidRequest = new ShipperCreateParameter
{
CompanyName = "", // 空字串,應該驗證失敗
Phone = ""
};
// Act
var response = await client.PostAsJsonAsync("/api/shippers", invalidRequest);
// Assert
response.Should().Be400BadRequest();
}
整合測試通常使用記憶體資料庫來避免對真實資料庫的依賴:
public class TestWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram>
where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 移除原本的資料庫設定
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// 加入記憶體資料庫
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDatabase");
});
// 建立資料庫並加入測試資料
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Database.EnsureCreated();
SeedTestData(context);
});
}
private static void SeedTestData(AppDbContext context)
{
if (!context.Shippers.Any())
{
context.Shippers.AddRange(
new Shipper
{
ShipperId = 1,
CompanyName = "測試物流A",
Phone = "02-12345678",
CreatedAt = DateTime.UtcNow
},
new Shipper
{
ShipperId = 2,
CompanyName = "測試物流B",
Phone = "02-87654321",
CreatedAt = DateTime.UtcNow
}
);
context.SaveChanges();
}
}
}
有時需要替換某些服務來避免外部依賴:
public class TestWebApplicationFactory<TProgram> : WebApplicationFactory<TProgram>
where TProgram : class
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
// 替換郵件服務為測試版本
services.Replace(ServiceDescriptor.Scoped<IEmailService, TestEmailService>());
// 替換外部 API 服務
services.Replace(ServiceDescriptor.Scoped<IExternalApiService, MockExternalApiService>());
// 替換檔案服務
services.Replace(ServiceDescriptor.Scoped<IFileService, InMemoryFileService>());
});
}
}
// 測試用的郵件服務
public class TestEmailService : IEmailService
{
public Task SendEmailAsync(string to, string subject, string body)
{
// 不實際發送郵件,只記錄
Console.WriteLine($"Test Email: To={to}, Subject={subject}");
return Task.CompletedTask;
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, config) =>
{
// 清除現有設定
config.Sources.Clear();
// 加入測試專用設定
config.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>("ConnectionStrings:DefaultConnection", "InMemory"),
new KeyValuePair<string, string>("Logging:LogLevel:Default", "Warning"),
new KeyValuePair<string, string>("ExternalApi:BaseUrl", "http://localhost:5000/test"),
});
});
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((context, config) =>
{
// 設定測試環境變數
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("TEST_MODE", "true");
});
}
專案結構應該清楚分離不同類型的測試:
tests/
├── Sample.WebApplication.UnitTests/ # 單元測試
├── Sample.WebApplication.Integration.Tests/ # 整合測試
└── Sample.WebApplication.E2ETests/ # 端對端測試
基本測試模板:
public class ShippersControllerIntegrationTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
public ShippersControllerIntegrationTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = _factory.CreateClient();
}
[Fact]
public async Task 測試方法名稱_測試條件_預期結果()
{
// Arrange - 準備測試資料和環境
// Act - 執行被測試的動作
// Assert - 驗證結果
}
}
環境隔離原則:
測試資料管理:
public abstract class IntegrationTestBase : IDisposable
{
protected readonly CustomWebApplicationFactory Factory;
protected readonly HttpClient Client;
protected IntegrationTestBase()
{
Factory = new CustomWebApplicationFactory();
Client = Factory.CreateClient();
}
protected async Task<int> SeedShipperAsync(string companyName, string phone = "02-12345678")
{
using var scope = Factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var shipper = new Shipper
{
CompanyName = companyName,
Phone = phone,
CreatedAt = DateTime.UtcNow
};
context.Shippers.Add(shipper);
await context.SaveChangesAsync();
return shipper.ShipperId;
}
protected async Task CleanupDatabaseAsync()
{
using var scope = Factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shippers.RemoveRange(context.Shippers);
await context.SaveChangesAsync();
}
public void Dispose()
{
Client?.Dispose();
Factory?.Dispose();
}
}
路由正確性測試:
注意:以下是示範用的測試程式碼,展示如何使用
[Theory]
和[InlineData]
來測試多個路由的正確性。實際範例專案中採用更具體的測試方法。
[Theory]
[InlineData("GET", "/api/shippers")]
[InlineData("GET", "/api/shippers/1")]
[InlineData("POST", "/api/shippers")]
[InlineData("PUT", "/api/shippers/1")]
[InlineData("DELETE", "/api/shippers/1")]
public async Task API路由_各種HTTP動詞_應正確對應到控制器動作(string method, string url)
{
// Arrange
using var client = _factory.CreateClient();
var request = new HttpRequestMessage(new HttpMethod(method), url);
if (method == "POST" || method == "PUT")
{
var content = new ShipperCreateParameter
{
CompanyName = "測試",
Phone = "02-12345678"
};
request.Content = JsonContent.Create(content);
}
// Act
var response = await client.SendAsync(request);
// Assert
response.Should().NotBe404NotFound();
}
完整的 CRUD 測試流程:
注意:以下是示範用的測試程式碼,展示完整的 CRUD 操作測試流程。實際範例專案中將 CRUD 操作分別實作為獨立的測試方法。
[Fact]
public async Task 貨運商CRUD操作_完整流程_應正常運作()
{
using var client = _factory.CreateClient();
// Create - 建立新貨運商
var createRequest = new ShipperCreateParameter
{
CompanyName = "CRUD測試公司",
Phone = "02-12345678"
};
var createResponse = await client.PostAsJsonAsync("/api/shippers", createRequest);
createResponse.Should().Be201Created();
var createdResult = await createResponse.Content.ReadFromJsonAsync<SuccessResultOutputModel<ShipperOutputModel>>();
var shipperId = createdResult!.Data.ShipperId;
// Read - 讀取貨運商資料
var readResponse = await client.GetAsync($"/api/shippers/{shipperId}");
readResponse.Should().Be200Ok();
var readResult = await readResponse.Content.ReadFromJsonAsync<SuccessResultOutputModel<ShipperOutputModel>>();
readResult!.Data.CompanyName.Should().Be(createRequest.CompanyName);
// Update - 更新貨運商資料
var updateRequest = new ShipperCreateParameter
{
CompanyName = "更新後的公司名稱",
Phone = "02-87654321"
};
var updateResponse = await client.PutAsJsonAsync($"/api/shippers/{shipperId}", updateRequest);
updateResponse.Should().Be200Ok();
var updatedResult = await updateResponse.Content.ReadFromJsonAsync<SuccessResultOutputModel<ShipperOutputModel>>();
updatedResult!.Data.CompanyName.Should().Be(updateRequest.CompanyName);
// Delete - 刪除貨運商
var deleteResponse = await client.DeleteAsync($"/api/shippers/{shipperId}");
deleteResponse.Should().Be204NoContent();
// 驗證刪除成功
var verifyResponse = await client.GetAsync($"/api/shippers/{shipperId}");
verifyResponse.Should().Be404NotFound();
}
注意:以下是示範用的測試程式碼,展示如何測試檔案上傳與下載功能。實際範例專案中並未包含這些功能的實作。
檔案上傳測試:
[Fact]
public async Task UploadFile_有效檔案_應上傳成功()
{
// Arrange
using var client = _factory.CreateClient();
var fileContent = "test file content"u8.ToArray();
using var content = new MultipartFormDataContent();
content.Add(new ByteArrayContent(fileContent), "file", "test.txt");
// Act
var response = await client.PostAsync("/api/files/upload", content);
// Assert
response.Should().Be200Ok();
var result = await response.Content.ReadFromJsonAsync<FileUploadResult>();
result!.FileName.Should().Be("test.txt");
result.FileSize.Should().Be(fileContent.Length);
}
檔案下載測試:
[Fact]
public async Task DownloadFile_存在的檔案_應下載成功()
{
// Arrange
using var client = _factory.CreateClient();
var fileId = await SeedFileAsync("download-test.txt", "download content");
// Act
var response = await client.GetAsync($"/api/files/{fileId}/download");
// Assert
response.Should().Be200Ok();
response.Content.Headers.ContentType?.MediaType.Should().Be("application/octet-stream");
var content = await response.Content.ReadAsStringAsync();
content.Should().Be("download content");
}
驗證錯誤處理的重要性:
參考實務經驗,整合測試中的錯誤處理驗證非常重要,包含以下場景:
完整的錯誤測試範例:
注意:以下是示範用的測試程式碼,展示如何測試各種錯誤情況的處理。實際範例專案中並未包含這些錯誤處理功能的實作。
[Fact]
public async Task GetShipper_ID格式正確但資料不存在_應回傳BadRequest()
{
// Arrange
using var client = _factory.CreateClient();
var nonExistentShipperId = 9999;
// Act
var response = await client.GetAsync($"/api/shippers/{nonExistentShipperId}");
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<FailInformation>(fail =>
{
fail.Message.Should().Contain("資料不存在");
});
}
[Fact]
public async Task GetShipper_沒有提供必要參數_應回傳ValidationError()
{
// Arrange
using var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/shippers/"); // 缺少 ID 參數
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationError>(error =>
{
error.Errors.Should().ContainKey("ShipperId");
});
}
[Fact]
public async Task GetShipper_ID格式不正確_應回傳ValidationError()
{
// Arrange
using var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/api/shippers/abc"); // 非數字格式
// Assert
response.Should().Be400BadRequest();
}
[Fact]
public async Task CreateShipper_沒有Content_應回傳UnsupportedMediaType()
{
// Arrange
using var client = _factory.CreateClient();
// Act
var response = await client.PostAsync("/api/shippers", null);
// Assert
response.Should().Be415UnsupportedMediaType();
}
錯誤回應的統一格式驗證:
注意:以下是示範用的測試程式碼,展示如何驗證錯誤回應的統一格式。實際範例專案中並未包含這些統一錯誤格式的實作。
// 定義標準的錯誤回應模型
public class FailInformation
{
public string Status { get; set; } = "Fail";
public string Message { get; set; } = string.Empty;
}
public class ValidationError
{
public string Status { get; set; } = "ValidationError";
public Dictionary<string, string[]> Errors { get; set; } = new();
}
// 在測試中驗證錯誤回應格式
[Fact]
public async Task CreateShipper_驗證失敗_應回傳統一錯誤格式()
{
// Arrange
using var client = _factory.CreateClient();
var invalidRequest = new ShipperCreateParameter
{
CompanyName = "", // 空字串,應該驗證失敗
Phone = ""
};
// Act
var response = await client.PostAsJsonAsync("/api/shippers", invalidRequest);
// Assert
response.Should().Be400BadRequest()
.And.Satisfy<ValidationError>(error =>
{
error.Status.Should().Be("ValidationError");
error.Errors.Should().ContainKey("CompanyName");
error.Errors.Should().ContainKey("Phone");
});
}
整合測試的兩種定義:
WebApplication 整合測試的必要性:
三個層級的測試策略:
兩種實作方法的選擇:
FluentAssertions.Web 的價值:
專案分離的重要性:
錯誤處理的完整性:
測試資料管理策略:
適合整合測試的場景:
不適合整合測試的場景:
整合測試是確保應用程式品質的重要環節,掌握了今天的基礎後,你已經具備建立可靠整合測試的能力。記住,好的整合測試不只能發現問題,更能幫助團隊快速定位和解決問題。
本篇文章的理論基礎來自於實際的企業級專案經驗,在 Day19 的範例專案中,我們實作了一個完整的整合測試範例,展示了從基礎到進階的測試策略:
範例專案結構:
Day19.Samples/
├── Day19.Samples.sln # 解決方案檔案
├── src/
│ └── Day19.WebApplication/ # 主要 Web API 專案
│ ├── Controllers/ # API 控制器
│ │ ├── ShippersController.cs # 主要的貨運商 API 控制器
│ │ └── Examples/ # 三個層級的範例控制器
│ │ ├── Level1/BasicApiController.cs # 簡單 WebApi 範例
│ │ ├── Level2/ServiceDependentController.cs # 服務依賴範例
│ │ └── Level3/FullDatabaseController.cs # 完整資料庫範例
│ ├── Data/ # 資料存取層
│ │ ├── AppDbContext.cs # Entity Framework 資料庫上下文
│ │ ├── ShippingContext.cs # 業務專用資料庫上下文
│ │ └── Entities/Shipper.cs # 實體類別
│ ├── Entities/
│ │ └── Shipper.cs # 貨運商實體類別
│ ├── Models/ # DTO 模型
│ │ ├── Common/ApiResponse.cs # 通用 API 回應模型
│ │ ├── ShipmentModels.cs # 運送相關模型
│ │ ├── ShipperCreateParameter.cs # 建立貨運商參數
│ │ ├── ShipperOutputModel.cs # 貨運商輸出模型
│ │ └── SuccessResultOutputModel.cs # 成功回應模型
│ ├── Services/ # 服務層
│ │ ├── IShipperService.cs # 貨運商服務介面
│ │ ├── ShipperService.cs # 貨運商服務實作
│ │ └── Level2ExampleServices.cs # Level 2 範例服務
│ ├── Program.cs # 應用程式進入點
│ ├── GlobalUsings.cs # 全域 using 設定
│ └── ShippingApi.http # HTTP 測試檔案
└── tests/
└── Day19.WebApplication.Integration.Tests/ # 整合測試專案
├── Controllers/ # 主要控制器測試
│ └── ShippersControllerTests.cs # 主要的整合測試類別 (42個測試)
├── Integration/ # 進階整合測試
│ └── AdvancedShippersControllerTests.cs # 進階測試案例
├── Examples/ # 三個層級的範例測試
│ ├── Level1/BasicApiControllerTests.cs # Level 1 測試範例
│ ├── Level2/ # Level 2 測試範例
│ │ ├── ServiceDependentControllerTests.cs # 服務依賴測試
│ │ └── ServiceStubWebApplicationFactory.cs # Level 2 測試工廠
│ └── Level3/ # Level 3 測試範例
│ ├── FullDatabaseIntegrationTests.cs # 完整資料庫測試
│ └── FullDatabaseWebApplicationFactory.cs # Level 3 測試工廠
├── Infrastructure/ # 測試基礎設施
│ └── CustomWebApplicationFactory.cs # 自訂測試工廠
├── IntegrationTestBase.cs # 整合測試基底類別
└── GlobalUsings.cs # 全域 using 設定
實際驗證的測試案例:
src/Day19.WebApplication/
)控制器層級:
ShippersController.cs
- 完整的貨運商 CRUD API,包含 GET、POST、PUT、DELETE 操作Examples/Level1/BasicApiController.cs
- 簡單的 API 範例,無外部依賴Examples/Level2/ServiceDependentController.cs
- 依賴服務注入的 API 範例Examples/Level3/FullDatabaseController.cs
- 完整資料庫操作的 API 範例資料存取層級:
Data/AppDbContext.cs
- Entity Framework 的主要資料庫上下文Data/ShippingContext.cs
- 業務專用的資料庫上下文Entities/Shipper.cs
- 貨運商實體類別,對應資料庫表格服務層級:
Services/IShipperService.cs
- 貨運商服務介面Services/ShipperService.cs
- 貨運商服務實作,處理業務邏輯Services/Level2ExampleServices.cs
- Level 2 範例中使用的服務模型層級:
Models/ShipperCreateParameter.cs
- 建立貨運商的輸入參數Models/ShipperOutputModel.cs
- 貨運商的輸出模型Models/SuccessResultOutputModel.cs
- 統一的成功回應格式tests/Day19.WebApplication.Integration.Tests/
)主要測試類別:
Controllers/ShippersControllerTests.cs
- 核心測試類別,包含 42 個測試案例:
Integration/AdvancedShippersControllerTests.cs
- 進階測試類別,展示複雜測試場景:
三個層級的範例測試:
Examples/Level1/BasicApiControllerTests.cs
- Level 1 測試:
Examples/Level2/ServiceDependentControllerTests.cs
- Level 2 測試:
Examples/Level2/ServiceStubWebApplicationFactory.cs
- Level 2 測試工廠:
Examples/Level3/FullDatabaseIntegrationTests.cs
- Level 3 測試:
Examples/Level3/FullDatabaseWebApplicationFactory.cs
- Level 3 測試工廠:
測試基礎設施:
Infrastructure/CustomWebApplicationFactory.cs
- 主要測試工廠:
IntegrationTestBase.cs
- 測試基底類別:
主要測試案例統計:
實際驗證的功能:
文章與範例專案的關係:
這個範例專案展示了使用 InMemory 資料庫進行整合測試的完整實作,包含了完整的 Controller → Service → DbContext 架構,是學習整合測試概念的理想起點。未來可以根據不同的需求,將此基礎擴展為更複雜的測試場景。
在 Day19 範例專案中,我們提供了三個不同層級的整合測試範例,對應不同的專案複雜度和學習階段:
範例檔案:
Controllers/Examples/Level1/BasicApiController.cs
tests/Examples/Level1/BasicApiControllerTests.cs
特色:
WebApplicationFactory<Program>
進行測試測試重點:
適合學習階段:初學者入門,了解整合測試基本概念
範例檔案:
Controllers/Examples/Level2/ServiceDependentController.cs
tests/Examples/Level2/ServiceDependentControllerTests.cs
tests/Examples/Level2/ServiceStubWebApplicationFactory.cs
特色:
測試重點:
技術展示:
// 在 ServiceStubWebApplicationFactory 中替換服務
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IExampleService>();
services.AddScoped(_ => serviceStub);
});
適合學習階段:有基礎概念後,學習處理依賴注入和服務模擬
範例檔案:
Controllers/Examples/Level3/FullDatabaseController.cs
tests/Examples/Level3/FullDatabaseWebApplicationFactory.cs
特色:
測試重點:
技術展示:
// 設定測試資料庫連線
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new[]
{
new KeyValuePair<string, string>(
"ConnectionStrings:DefaultConnection",
"InMemory Database for Testing")
});
});
適合學習階段:進階學習者,準備應用到真實專案
特色:
優點:
缺點:
適用場景:專注於 API 介面和控制器邏輯的測試
特色:
優點:
缺點:
適用場景:完整的端到端驗證和資料流程測試
GitHub Repository:
NuGet 套件:
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第十九天。明天會介紹 Day 20 – Testcontainers 初探:使用 Docker 架設測試環境。