第十七章:測試策略與自動化 (Testing Strategy)
17.1 引言:測試金字塔
在軟體開發中,測試是保證品質的唯一途徑。測試金字塔 (Test Pyramid) 是業界公認的最佳實踐。
/\
/E2E\ ← 10% (慢、脆弱、昂貴)
/------\
/Integr.\ ← 20% (中速、較穩定)
/----------\
/Unit Tests \ ← 70% (快、穩定、便宜)
/--------------\- 單元測試 (Unit Tests):測試單一類別或方法,完全隔離外部依賴。
- 整合測試 (Integration Tests):測試多個元件在真實環境 (資料庫、訊息佇列) 下的互動。
- 端對端測試 (E2E Tests):模擬真實使用者操作,測試整個系統。
17.2 單元測試 (Unit Tests)
單元測試應該 快速、獨立、可重複。
1. 測試領域層 (Domain Layer)
領域層是最容易測試的,因為它不依賴外部資源。
csharp
public class OrderTests
{
[Fact]
public void AddItem_WithZeroQuantity_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001");
// Act & Assert
var exception = Assert.Throws<BusinessException>(() =>
order.AddItem(Guid.NewGuid(), 0, 100m)
);
Assert.Equal("Order:InvalidQuantity", exception.Code);
}
[Fact]
public void AddItem_WithValidQuantity_ShouldIncreaseTotal()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001");
// Act
order.AddItem(Guid.NewGuid(), 2, 100m);
order.AddItem(Guid.NewGuid(), 3, 50m);
// Assert
Assert.Equal(350m, order.TotalAmount);
Assert.Equal(2, order.Items.Count);
}
[Theory]
[InlineData(1, 100, 100)]
[InlineData(5, 20, 100)]
[InlineData(10, 15.5, 155)]
public void AddItem_ShouldCalculateCorrectTotal(int qty, decimal price, decimal expectedTotal)
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001");
// Act
order.AddItem(Guid.NewGuid(), qty, price);
// Assert
Assert.Equal(expectedTotal, order.TotalAmount);
}
}2. 使用 Moq 測試應用層
應用層通常依賴 Repository 與領域服務,我們使用 Moq 來模擬這些依賴。
csharp
public class BookAppServiceTests
{
[Fact]
public async Task CreateAsync_ShouldCallRepository()
{
// Arrange
var mockRepo = new Mock<IRepository<Book, Guid>>();
var mockGuidGenerator = new Mock<IGuidGenerator>();
mockGuidGenerator.Setup(x => x.Create()).Returns(Guid.NewGuid());
var appService = new BookAppService(mockRepo.Object, mockGuidGenerator.Object);
var input = new CreateBookDto { Name = "1984", Price = 15.99f };
// Act
await appService.CreateAsync(input);
// Assert
mockRepo.Verify(r => r.InsertAsync(
It.Is<Book>(b => b.Name == "1984"),
It.IsAny<bool>(),
It.IsAny<CancellationToken>()
), Times.Once);
}
}17.3 整合測試 (Integration Tests)
整合測試使用真實的資料庫與其他基礎設施。
1. ABP 測試基類
ABP 提供了 AbpIntegratedTest<TStartupModule> 基類,它會啟動完整的 ABP 應用程式 (包含 DI、模組系統)。
csharp
public class BookStoreTestBase : AbpIntegratedTest<BookStoreTestModule>
{
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
}
[DependsOn(
typeof(BookStoreApplicationModule),
typeof(BookStoreEntityFrameworkCoreTestModule)
)]
public class BookStoreTestModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// 使用 In-Memory 資料庫
context.Services.AddEntityFrameworkInMemoryDatabase();
var databaseName = Guid.NewGuid().ToString();
Configure<AbpDbContextOptions>(options =>
{
options.Configure(abpDbContextConfigurationContext =>
{
abpDbContextConfigurationContext.DbContextOptions.UseInMemoryDatabase(databaseName);
});
});
}
}2. 撰寫整合測試
csharp
public class BookAppService_IntegrationTests : BookStoreTestBase
{
private readonly IBookAppService _bookAppService;
private readonly IRepository<Book, Guid> _bookRepository;
public BookAppService_IntegrationTests()
{
_bookAppService = GetRequiredService<IBookAppService>();
_bookRepository = GetRequiredService<IRepository<Book, Guid>>();
}
[Fact]
public async Task CreateAsync_ShouldPersistToDatabase()
{
// Arrange
var input = new CreateBookDto
{
Name = "The Hobbit",
Type = BookType.Fantasy,
PublishDate = new DateTime(1937, 9, 21),
Price = 12.99f
};
// Act
var result = await _bookAppService.CreateAsync(input);
// Assert
result.Id.ShouldNotBe(Guid.Empty);
// 驗證資料庫中確實存在
var bookInDb = await _bookRepository.GetAsync(result.Id);
bookInDb.ShouldNotBeNull();
bookInDb.Name.ShouldBe("The Hobbit");
}
}17.4 使用 Testcontainers
In-Memory 資料庫雖然快速,但它與真實資料庫的行為可能不同 (例如 SQL Server 的某些函式在 SQLite 中不存在)。Testcontainers 允許我們在測試中啟動真實的 Docker 容器。
1. 安裝套件
bash
dotnet add package Testcontainers.MsSql2. 建立測試 Fixture
csharp
public class DatabaseFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("YourStrong@Passw0rd")
.Build();
public string ConnectionString => _container.GetConnectionString();
public async Task InitializeAsync()
{
await _container.StartAsync();
}
public async Task DisposeAsync()
{
await _container.DisposeAsync();
}
}3. 在測試中使用
csharp
public class BookRepository_RealDatabase_Tests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _fixture;
private readonly BookStoreDbContext _dbContext;
public BookRepository_RealDatabase_Tests(DatabaseFixture fixture)
{
_fixture = fixture;
var options = new DbContextOptionsBuilder<BookStoreDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
_dbContext = new BookStoreDbContext(options);
_dbContext.Database.Migrate(); // 執行 Migration
}
[Fact]
public async Task ComplexQuery_ShouldWork()
{
// 測試複雜的 SQL 查詢,確保在真實資料庫上正常運作
var books = await _dbContext.Books
.Where(b => EF.Functions.Like(b.Name, "%Lord%"))
.OrderByDescending(b => b.PublishDate)
.Take(10)
.ToListAsync();
// ...
}
}17.5 CI/CD 整合
1. GitHub Actions 範例
yaml
name: Test & Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: Y
SA_PASSWORD: YourStrong@Passw0rd
ports:
- 1433:1433
options: >-
--health-cmd="/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -Q 'SELECT 1'"
--health-interval=10s
--health-timeout=3s
--health-retries=3
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Run Unit Tests
run: dotnet test --no-build --configuration Release --filter "Category=Unit" --logger "trx;LogFileName=unit-tests.trx"
- name: Run Integration Tests
env:
ConnectionStrings__Default: "Server=localhost,1433;Database=BookStore_Test;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True"
run: dotnet test --no-build --configuration Release --filter "Category=Integration" --logger "trx;LogFileName=integration-tests.trx"
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: "**/TestResults/*.trx"
reporter: dotnet-trx17.6 測試最佳實踐
1. AAA 模式 (Arrange-Act-Assert)
每個測試都應該清楚地分為三個部分。
2. 測試命名
使用描述性的名稱:MethodName_Scenario_ExpectedBehavior
- 例如:
CreateAsync_WithDuplicateName_ShouldThrow
3. 避免測試實作細節
測試應該關注 行為,而非實作。
- 錯誤:測試某個私有方法是否被呼叫。
- 正確:測試公開 API 的輸出是否符合預期。
4. 測試資料建構器
使用 Builder 模式建立測試資料,提高可讀性。
csharp
public class BookBuilder
{
private string _name = "Default Book";
private BookType _type = BookType.Fiction;
private float _price = 10f;
public BookBuilder WithName(string name)
{
_name = name;
return this;
}
public BookBuilder WithPrice(float price)
{
_price = price;
return this;
}
public Book Build()
{
return new Book(Guid.NewGuid(), _name, _type, DateTime.Now, _price);
}
}
// 使用
var book = new BookBuilder()
.WithName("1984")
.WithPrice(15.99f)
.Build();17.7 實戰練習
練習 1:單元測試覆蓋
- 為
Order聚合根撰寫至少 10 個單元測試,涵蓋所有業務規則。 - 使用
[Theory]測試邊界條件。
練習 2:整合測試
- 為
BookAppService撰寫整合測試,測試 CRUD 操作。 - 使用 Testcontainers 啟動真實的 SQL Server。
練習 3:CI/CD
- 在 GitHub 上建立一個 Repository。
- 配置 GitHub Actions,在每次 Push 時自動執行測試。
- 設定測試失敗時發送通知。
17.8 總結
測試是軟體品質的保證。
- 單元測試 快速驗證業務邏輯。
- 整合測試 確保元件正確協作。
- Testcontainers 提供真實的測試環境。
- CI/CD 自動化測試流程,及早發現問題。
在下一章,我們將探討 效能優化 (Performance Optimization),學習如何讓 ABP 應用程式更快、更穩定。
參考資源: