第十七章:測試策略與自動化 - 習題解答
本文件提供第十七章實戰練習的完整解答,涵蓋單元測試、整合測試和 CI/CD 配置。
練習 1:單元測試覆蓋
題目
- 為
Order聚合根撰寫至少 10 個單元測試,涵蓋所有業務規則。 - 使用
[Theory]測試邊界條件。
解答
步驟 1:定義 Order 聚合根(參考)
csharp
// Domain/Orders/Order.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Volo.Abp;
using Volo.Abp.Domain.Entities.Auditing;
namespace BookStore.Orders
{
public class Order : FullAuditedAggregateRoot<Guid>
{
public string OrderNumber { get; private set; }
public OrderStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public Guid CustomerId { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
protected Order()
{
// 用於 ORM
}
public Order(Guid id, string orderNumber, Guid customerId)
: base(id)
{
OrderNumber = Check.NotNullOrWhiteSpace(orderNumber, nameof(orderNumber));
CustomerId = customerId;
Status = OrderStatus.Pending;
TotalAmount = 0;
}
public void AddItem(Guid productId, int quantity, decimal unitPrice)
{
if (quantity <= 0)
{
throw new BusinessException("Order:InvalidQuantity")
.WithData("Quantity", quantity);
}
if (unitPrice < 0)
{
throw new BusinessException("Order:InvalidPrice")
.WithData("UnitPrice", unitPrice);
}
if (Status != OrderStatus.Pending)
{
throw new BusinessException("Order:CannotModifyNonPendingOrder");
}
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(new OrderItem(Guid.NewGuid(), productId, quantity, unitPrice));
}
RecalculateTotal();
}
public void RemoveItem(Guid productId)
{
if (Status != OrderStatus.Pending)
{
throw new BusinessException("Order:CannotModifyNonPendingOrder");
}
var item = _items.FirstOrDefault(i => i.ProductId == productId);
if (item == null)
{
throw new BusinessException("Order:ItemNotFound");
}
_items.Remove(item);
RecalculateTotal();
}
public void Confirm()
{
if (Status != OrderStatus.Pending)
{
throw new BusinessException("Order:CannotConfirmNonPendingOrder");
}
if (!_items.Any())
{
throw new BusinessException("Order:CannotConfirmEmptyOrder");
}
Status = OrderStatus.Confirmed;
}
public void Cancel()
{
if (Status == OrderStatus.Completed || Status == OrderStatus.Cancelled)
{
throw new BusinessException("Order:CannotCancelCompletedOrCancelledOrder");
}
Status = OrderStatus.Cancelled;
}
public void Complete()
{
if (Status != OrderStatus.Confirmed)
{
throw new BusinessException("Order:CanOnlyCompleteConfirmedOrder");
}
Status = OrderStatus.Completed;
}
private void RecalculateTotal()
{
TotalAmount = _items.Sum(i => i.TotalPrice);
}
}
public enum OrderStatus
{
Pending,
Confirmed,
Completed,
Cancelled
}
public class OrderItem : Entity<Guid>
{
public Guid ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal UnitPrice { get; private set; }
public decimal TotalPrice => Quantity * UnitPrice;
protected OrderItem() { }
public OrderItem(Guid id, Guid productId, int quantity, decimal unitPrice)
: base(id)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
public void IncreaseQuantity(int quantity)
{
Quantity += quantity;
}
}
}步驟 2:撰寫單元測試
csharp
// Test/Domain/Orders/OrderTests.cs
using System;
using Shouldly;
using Volo.Abp;
using Xunit;
namespace BookStore.Orders
{
public class OrderTests
{
[Fact]
public void Constructor_ShouldCreatePendingOrder()
{
// Arrange & Act
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Assert
order.OrderNumber.ShouldBe("ORD-001");
order.Status.ShouldBe(OrderStatus.Pending);
order.TotalAmount.ShouldBe(0);
order.Items.ShouldBeEmpty();
}
[Fact]
public void Constructor_WithNullOrderNumber_ShouldThrow()
{
// Arrange, Act & Assert
Should.Throw<ArgumentException>(() =>
new Order(Guid.NewGuid(), null, Guid.NewGuid()));
}
[Fact]
public void AddItem_WithValidData_ShouldAddItem()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, 2, 100m);
// Assert
order.Items.Count.ShouldBe(1);
order.Items[0].ProductId.ShouldBe(productId);
order.Items[0].Quantity.ShouldBe(2);
order.TotalAmount.ShouldBe(200m);
}
[Fact]
public void AddItem_WithZeroQuantity_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act & Assert
var exception = Should.Throw<BusinessException>(() =>
order.AddItem(Guid.NewGuid(), 0, 100m));
exception.Code.ShouldBe("Order:InvalidQuantity");
}
[Fact]
public void AddItem_WithNegativePrice_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act & Assert
var exception = Should.Throw<BusinessException>(() =>
order.AddItem(Guid.NewGuid(), 1, -10m));
exception.Code.ShouldBe("Order:InvalidPrice");
}
[Theory]
[InlineData(1, 100, 100)]
[InlineData(5, 20, 100)]
[InlineData(10, 15.5, 155)]
[InlineData(100, 1.99, 199)]
public void AddItem_ShouldCalculateCorrectTotal(int quantity, decimal price, decimal expectedTotal)
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act
order.AddItem(Guid.NewGuid(), quantity, price);
// Assert
order.TotalAmount.ShouldBe(expectedTotal);
}
[Fact]
public void AddItem_SameProduct_ShouldIncreaseQuantity()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
var productId = Guid.NewGuid();
// Act
order.AddItem(productId, 2, 100m);
order.AddItem(productId, 3, 100m);
// Assert
order.Items.Count.ShouldBe(1);
order.Items[0].Quantity.ShouldBe(5);
order.TotalAmount.ShouldBe(500m);
}
[Fact]
public void AddItem_ToNonPendingOrder_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
order.AddItem(Guid.NewGuid(), 1, 100m);
order.Confirm();
// Act & Assert
Should.Throw<BusinessException>(() =>
order.AddItem(Guid.NewGuid(), 1, 100m));
}
[Fact]
public void RemoveItem_ExistingItem_ShouldRemoveAndRecalculate()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
var productId1 = Guid.NewGuid();
var productId2 = Guid.NewGuid();
order.AddItem(productId1, 2, 100m);
order.AddItem(productId2, 3, 50m);
// Act
order.RemoveItem(productId1);
// Assert
order.Items.Count.ShouldBe(1);
order.TotalAmount.ShouldBe(150m);
}
[Fact]
public void RemoveItem_NonExistingItem_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act & Assert
Should.Throw<BusinessException>(() =>
order.RemoveItem(Guid.NewGuid()));
}
[Fact]
public void Confirm_ValidOrder_ShouldChangeStatus()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
order.AddItem(Guid.NewGuid(), 1, 100m);
// Act
order.Confirm();
// Assert
order.Status.ShouldBe(OrderStatus.Confirmed);
}
[Fact]
public void Confirm_EmptyOrder_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act & Assert
Should.Throw<BusinessException>(() => order.Confirm());
}
[Fact]
public void Cancel_PendingOrder_ShouldChangeStatus()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act
order.Cancel();
// Assert
order.Status.ShouldBe(OrderStatus.Cancelled);
}
[Fact]
public void Cancel_CompletedOrder_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
order.AddItem(Guid.NewGuid(), 1, 100m);
order.Confirm();
order.Complete();
// Act & Assert
Should.Throw<BusinessException>(() => order.Cancel());
}
[Fact]
public void Complete_ConfirmedOrder_ShouldChangeStatus()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
order.AddItem(Guid.NewGuid(), 1, 100m);
order.Confirm();
// Act
order.Complete();
// Assert
order.Status.ShouldBe(OrderStatus.Completed);
}
[Fact]
public void Complete_PendingOrder_ShouldThrow()
{
// Arrange
var order = new Order(Guid.NewGuid(), "ORD-001", Guid.NewGuid());
// Act & Assert
Should.Throw<BusinessException>(() => order.Complete());
}
}
}練習 2:整合測試
題目
- 為
BookAppService撰寫整合測試,測試 CRUD 操作。 - 使用 Testcontainers 啟動真實的 SQL Server。
解答
步驟 1:安裝 Testcontainers
bash
dotnet add package Testcontainers.MsSql步驟 2:建立測試基類
csharp
// Test/BookStoreTestBase.cs
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.MsSql;
using Volo.Abp;
using Volo.Abp.Testing;
using Xunit;
namespace BookStore
{
public abstract class BookStoreTestBase<TStartupModule> :
AbpIntegratedTest<TStartupModule>,
IAsyncLifetime
where TStartupModule : IAbpModule
{
protected readonly MsSqlContainer _sqlContainer;
protected BookStoreTestBase()
{
_sqlContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("YourStrong@Passw0rd")
.Build();
}
public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();
}
public async Task DisposeAsync()
{
await _sqlContainer.DisposeAsync();
}
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
}
}步驟 3:配置測試模組
csharp
// Test/BookStoreApplicationTestModule.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.EntityFrameworkCore;
using Volo.Abp.Modularity;
namespace BookStore
{
[DependsOn(
typeof(BookStoreApplicationModule),
typeof(BookStoreEntityFrameworkCoreModule),
typeof(AbpTestBaseModule)
)]
public class BookStoreApplicationTestModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
// 使用 Testcontainers 提供的連線字串
var connectionString = context.Services.GetSingletonInstance<MsSqlContainer>()
.GetConnectionString();
Configure<AbpDbContextOptions>(options =>
{
options.Configure(abpDbContextConfigurationContext =>
{
abpDbContextConfigurationContext.DbContextOptions
.UseSqlServer(connectionString);
});
});
}
}
}步驟 4:撰寫整合測試
csharp
// Test/Application/Books/BookAppService_IntegrationTests.cs
using System;
using System.Threading.Tasks;
using BookStore.Books;
using Shouldly;
using Volo.Abp.Domain.Repositories;
using Xunit;
namespace BookStore.Application.Books
{
public class BookAppService_IntegrationTests : BookStoreTestBase<BookStoreApplicationTestModule>
{
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 CreateUpdateBookDto
{
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);
result.Name.ShouldBe("The Hobbit");
// 驗證資料庫中確實存在
var bookInDb = await _bookRepository.GetAsync(result.Id);
bookInDb.ShouldNotBeNull();
bookInDb.Name.ShouldBe("The Hobbit");
bookInDb.Type.ShouldBe(BookType.Fantasy);
}
[Fact]
public async Task GetAsync_ExistingBook_ShouldReturnBook()
{
// Arrange
var book = new Book(Guid.NewGuid(), "1984", BookType.Dystopia, DateTime.Now, 15.99f);
await _bookRepository.InsertAsync(book);
// Act
var result = await _bookAppService.GetAsync(book.Id);
// Assert
result.ShouldNotBeNull();
result.Id.ShouldBe(book.Id);
result.Name.ShouldBe("1984");
}
[Fact]
public async Task GetListAsync_ShouldReturnPagedResult()
{
// Arrange
await _bookRepository.InsertAsync(new Book(Guid.NewGuid(), "Book 1", BookType.Fiction, DateTime.Now, 10f));
await _bookRepository.InsertAsync(new Book(Guid.NewGuid(), "Book 2", BookType.Fiction, DateTime.Now, 20f));
await _bookRepository.InsertAsync(new Book(Guid.NewGuid(), "Book 3", BookType.Fiction, DateTime.Now, 30f));
// Act
var result = await _bookAppService.GetListAsync(new PagedAndSortedResultRequestDto
{
MaxResultCount = 2,
SkipCount = 0
});
// Assert
result.TotalCount.ShouldBeGreaterThanOrEqualTo(3);
result.Items.Count.ShouldBe(2);
}
[Fact]
public async Task UpdateAsync_ShouldModifyBook()
{
// Arrange
var book = new Book(Guid.NewGuid(), "Original Name", BookType.Fiction, DateTime.Now, 10f);
await _bookRepository.InsertAsync(book);
var updateDto = new CreateUpdateBookDto
{
Name = "Updated Name",
Type = BookType.ScienceFiction,
PublishDate = DateTime.Now,
Price = 20f
};
// Act
await _bookAppService.UpdateAsync(book.Id, updateDto);
// Assert
var updatedBook = await _bookRepository.GetAsync(book.Id);
updatedBook.Name.ShouldBe("Updated Name");
updatedBook.Type.ShouldBe(BookType.ScienceFiction);
updatedBook.Price.ShouldBe(20f);
}
[Fact]
public async Task DeleteAsync_ShouldRemoveBook()
{
// Arrange
var book = new Book(Guid.NewGuid(), "To Delete", BookType.Fiction, DateTime.Now, 10f);
await _bookRepository.InsertAsync(book);
// Act
await _bookAppService.DeleteAsync(book.Id);
// Assert
var exists = await _bookRepository.AnyAsync(b => b.Id == book.Id);
exists.ShouldBeFalse();
}
}
}練習 3:CI/CD
題目
- 在 GitHub 上建立一個 Repository。
- 配置 GitHub Actions,在每次 Push 時自動執行測試。
- 設定測試失敗時發送通知。
解答
步驟 1:建立 GitHub Actions Workflow
.github/workflows/test.yml
name: Test
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:
- name: Checkout code
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" \
--collect:"XPlat Code Coverage"
- 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" \
--collect:"XPlat Code Coverage"
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: "**/TestResults/*.trx"
- name: Upload Code Coverage
uses: codecov/codecov-action@v3
with:
files: "**/coverage.cobertura.xml"
fail_ci_if_error: true
- name: Publish Test Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: "**/TestResults/*.trx"
reporter: dotnet-trx
- name: Send Slack Notification on Failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "❌ Tests failed for ${{ github.repository }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Tests Failed*\n\nRepository: ${{ github.repository }}\nBranch: ${{ github.ref }}\nCommit: ${{ github.sha }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>"
}
}
]
}步驟 2:配置測試分類
在測試類別上加入 [Trait] 屬性:
csharp
// 單元測試
[Trait("Category", "Unit")]
public class OrderTests
{
// ...
}
// 整合測試
[Trait("Category", "Integration")]
public class BookAppService_IntegrationTests
{
// ...
}步驟 3:設定 Slack Webhook(選擇性)
- 在 Slack 中建立 Incoming Webhook
- 在 GitHub Repository 的 Settings → Secrets 中新增
SLACK_WEBHOOK_URL
總結
本章練習涵蓋了完整的測試策略:
單元測試:
- 測試領域邏輯的所有業務規則
- 使用
[Theory]測試邊界條件 - 遵循 AAA 模式(Arrange-Act-Assert)
整合測試:
- 使用 Testcontainers 提供真實的資料庫環境
- 測試完整的 CRUD 操作
- 驗證資料持久化
CI/CD:
- 自動化測試執行
- 程式碼覆蓋率報告
- 測試失敗通知
最佳實踐:
- 保持測試的獨立性和可重複性
- 使用描述性的測試名稱
- 測試行為而非實作細節
- 定期執行測試並監控覆蓋率
- 將測試整合到 CI/CD 流程中