Skip to content

第十七章:測試策略與自動化 (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.MsSql

2. 建立測試 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-trx

17.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:單元測試覆蓋

  1. Order 聚合根撰寫至少 10 個單元測試,涵蓋所有業務規則。
  2. 使用 [Theory] 測試邊界條件。

練習 2:整合測試

  1. BookAppService 撰寫整合測試,測試 CRUD 操作。
  2. 使用 Testcontainers 啟動真實的 SQL Server。

練習 3:CI/CD

  1. 在 GitHub 上建立一個 Repository。
  2. 配置 GitHub Actions,在每次 Push 時自動執行測試。
  3. 設定測試失敗時發送通知。

17.8 總結

測試是軟體品質的保證。

  • 單元測試 快速驗證業務邏輯。
  • 整合測試 確保元件正確協作。
  • Testcontainers 提供真實的測試環境。
  • CI/CD 自動化測試流程,及早發現問題。

在下一章,我們將探討 效能優化 (Performance Optimization),學習如何讓 ABP 應用程式更快、更穩定。


參考資源

Released under the MIT License.