--- name: dotnet-testing-advanced-testcontainers-nosql description: | Testcontainers NoSQL 整合測試完整指南。 涵蓋 MongoDB 文件操作、Redis 五種資料結構、Collection Fixture 模式。 包含 BSON 序列化、索引效能測試、資料隔離策略與容器生命週期管理。 triggers: # 核心關鍵字 - testcontainers mongodb - testcontainers redis - mongodb integration test - redis integration test - nosql testing # 技術術語 - MongoDbContainer - RedisContainer - IMongoDatabase - IConnectionMultiplexer - BSON serialization # 類別/方法 - MongoDbContainerFixture - RedisContainerFixture - BsonDocument - BsonElement - SetStringAsync - ListLeftPushAsync - SortedSetAddAsync # 使用情境 - 文件模型測試 - 快取測試 - 五種資料結構 - String Hash List Set SortedSet - 複合索引 - 唯一索引 - Collection Fixture license: MIT metadata: author: Kevin Tseng version: "1.0.0" tags: "testcontainers, mongodb, redis, nosql, integration-testing, bson" --- # Testcontainers NoSQL 整合測試指南 ## 適用情境 當被要求執行以下任務時,請使用此技能: - 使用 Testcontainers 測試 MongoDB 文件操作 - 使用 Testcontainers 測試 Redis 快取服務 - 建立 MongoDB Collection Fixture 共享容器 - 建立 Redis Collection Fixture 共享容器 - 測試 MongoDB BSON 序列化與複雜文件結構 - 測試 MongoDB 索引效能與唯一性約束 - 測試 Redis 五種資料結構(String、Hash、List、Set、Sorted Set) - 實作 NoSQL 資料庫的資料隔離策略 ## 核心概念 ### NoSQL 測試的挑戰 NoSQL 資料庫測試與關聯式資料庫有顯著差異: 1. **文件模型複雜度**:MongoDB 支援巢狀物件、陣列、字典等複雜結構 2. **無固定 Schema**:需要透過測試驗證資料結構的一致性 3. **多樣化資料結構**:Redis 有五種主要資料結構,各有不同使用場景 4. **序列化處理**:BSON (MongoDB) 與 JSON (Redis) 序列化行為需要驗證 ### Testcontainers 優勢 - **真實環境模擬**:使用實際的 MongoDB 7.0 和 Redis 7.2 容器 - **一致性測試**:測試結果直接反映正式環境行為 - **隔離性保證**:每個測試環境完全獨立 - **效能驗證**:可進行真實的索引效能測試 ## 環境需求 ### 必要套件 ```xml net9.0 enable enable false ``` ### 套件版本說明 | 套件 | 版本 | 用途 | | ---------------------- | ------ | ---------------------------------- | | MongoDB.Driver | 3.0.0 | MongoDB 官方驅動程式,支援最新功能 | | MongoDB.Bson | 3.0.0 | BSON 序列化處理 | | StackExchange.Redis | 2.8.16 | Redis 客戶端,支援 Redis 7.x | | Testcontainers.MongoDb | 4.0.0 | MongoDB 容器管理 | | Testcontainers.Redis | 4.0.0 | Redis 容器管理 | --- ## MongoDB 容器化測試 ### MongoDB Container Fixture 使用 Collection Fixture 模式共享容器,節省 80% 以上的測試時間: ```csharp using MongoDB.Driver; using Testcontainers.MongoDb; namespace YourProject.Integration.Tests.Fixtures; /// /// MongoDB 容器 Fixture - 實作 IAsyncLifetime 管理容器生命週期 /// public class MongoDbContainerFixture : IAsyncLifetime { private MongoDbContainer? _container; public IMongoDatabase Database { get; private set; } = null!; public string ConnectionString { get; private set; } = string.Empty; public string DatabaseName { get; } = "testdb"; public async Task InitializeAsync() { // 使用 MongoDB 7.0 確保功能完整性 _container = new MongoDbBuilder() .WithImage("mongo:7.0") .WithPortBinding(27017, true) .Build(); await _container.StartAsync(); ConnectionString = _container.GetConnectionString(); var client = new MongoClient(ConnectionString); Database = client.GetDatabase(DatabaseName); } public async Task DisposeAsync() { if (_container != null) { await _container.DisposeAsync(); } } /// /// 清空資料庫中的所有集合 - 用於測試間隔離 /// public async Task ClearDatabaseAsync() { var collections = await Database.ListCollectionNamesAsync(); await collections.ForEachAsync(async collectionName => { await Database.DropCollectionAsync(collectionName); }); } } /// /// 定義使用 MongoDB Fixture 的測試集合 /// [CollectionDefinition("MongoDb Collection")] public class MongoDbCollectionFixture : ICollectionFixture { // 此類別不需要實作,僅用於標記集合 } ``` ### MongoDB 文件模型設計 建立包含巢狀物件、陣列、字典等複雜結構的文件模型: ```csharp using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; namespace YourProject.Core.Models.Mongo; /// /// 使用者文件 - 展示 MongoDB 複雜文件結構 /// public class UserDocument { [BsonId] [BsonRepresentation(BsonType.ObjectId)] public string Id { get; set; } = string.Empty; [BsonElement("username")] [BsonRequired] public string Username { get; set; } = string.Empty; [BsonElement("email")] [BsonRequired] public string Email { get; set; } = string.Empty; [BsonElement("profile")] public UserProfile Profile { get; set; } = new(); [BsonElement("addresses")] public List
Addresses { get; set; } = new(); [BsonElement("skills")] public List Skills { get; set; } = new(); [BsonElement("preferences")] public Dictionary Preferences { get; set; } = new(); [BsonElement("created_at")] [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] public DateTime CreatedAt { get; set; } [BsonElement("updated_at")] [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] public DateTime UpdatedAt { get; set; } [BsonElement("is_active")] public bool IsActive { get; set; } = true; [BsonElement("version")] public int Version { get; set; } = 1; /// /// 樂觀鎖定版本遞增 /// public void IncrementVersion(DateTime updateTime) { Version++; UpdatedAt = updateTime; } } /// /// 使用者檔案 - 巢狀文件範例 /// public class UserProfile { [BsonElement("first_name")] public string FirstName { get; set; } = string.Empty; [BsonElement("last_name")] public string LastName { get; set; } = string.Empty; [BsonElement("birth_date")] [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] public DateTime? BirthDate { get; set; } [BsonElement("bio")] public string Bio { get; set; } = string.Empty; [BsonElement("social_links")] public Dictionary SocialLinks { get; set; } = new(); [BsonIgnore] public string FullName => $"{FirstName} {LastName}".Trim(); } /// /// 地址模型 - 用於地理空間查詢 /// public class Address { [BsonElement("type")] public string Type { get; set; } = string.Empty; // "home", "work", "other" [BsonElement("city")] public string City { get; set; } = string.Empty; [BsonElement("country")] public string Country { get; set; } = string.Empty; [BsonElement("location")] public GeoLocation? Location { get; set; } [BsonElement("is_primary")] public bool IsPrimary { get; set; } } /// /// 地理位置 - GeoJSON 格式 /// public class GeoLocation { [BsonElement("type")] public string Type { get; set; } = "Point"; [BsonElement("coordinates")] public double[] Coordinates { get; set; } = new double[2]; // [longitude, latitude] public static GeoLocation CreatePoint(double longitude, double latitude) { return new GeoLocation { Type = "Point", Coordinates = new[] { longitude, latitude } }; } } /// /// 技能模型 - 陣列查詢範例 /// public class Skill { [BsonElement("name")] public string Name { get; set; } = string.Empty; [BsonElement("level")] public SkillLevel Level { get; set; } = SkillLevel.Beginner; [BsonElement("years_experience")] public int YearsExperience { get; set; } [BsonElement("verified")] public bool Verified { get; set; } } /// /// 技能等級列舉 /// public enum SkillLevel { [BsonRepresentation(BsonType.String)] Beginner, [BsonRepresentation(BsonType.String)] Intermediate, [BsonRepresentation(BsonType.String)] Advanced, [BsonRepresentation(BsonType.String)] Expert } ``` ### BSON 序列化測試 驗證 BSON 序列化行為: ```csharp using MongoDB.Bson; using AwesomeAssertions; namespace YourProject.Integration.Tests.MongoDB; public class MongoBsonTests { [Fact] public void ObjectId產生_應產生有效的ObjectId() { // Arrange & Act var objectId = ObjectId.GenerateNewId(); // Assert objectId.Should().NotBeNull(); objectId.CreationTime.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1)); objectId.ToString().Should().HaveLength(24); } [Fact] public void BsonDocument建立_當傳入null值_應正確處理() { // Arrange var doc = new BsonDocument { ["name"] = "John", ["email"] = BsonNull.Value, ["age"] = 25 }; // Act var json = doc.ToJson(); // Assert json.Should().Contain("\"email\" : null"); doc["email"].IsBsonNull.Should().BeTrue(); } [Fact] public void BsonArray操作_當使用複雜陣列_應正確處理() { // Arrange var skills = new BsonArray { new BsonDocument { ["name"] = "C#", ["level"] = 5 }, new BsonDocument { ["name"] = "MongoDB", ["level"] = 3 } }; var doc = new BsonDocument { ["userId"] = ObjectId.GenerateNewId(), ["skills"] = skills }; // Act var skillsArray = doc["skills"].AsBsonArray; var firstSkill = skillsArray[0].AsBsonDocument; // Assert skillsArray.Should().HaveCount(2); firstSkill["name"].AsString.Should().Be("C#"); firstSkill["level"].AsInt32.Should().Be(5); } } ``` ### MongoDB CRUD 測試 ```csharp using MongoDB.Driver; using AwesomeAssertions; using Microsoft.Extensions.Time.Testing; namespace YourProject.Integration.Tests.MongoDB; [Collection("MongoDb Collection")] public class MongoUserServiceTests { private readonly MongoUserService _mongoUserService; private readonly IMongoDatabase _database; private readonly FakeTimeProvider _fakeTimeProvider; public MongoUserServiceTests(MongoDbContainerFixture fixture) { _database = fixture.Database; _fakeTimeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); // 建立服務實例 _mongoUserService = new MongoUserService( _database, Options.Create(new MongoDbSettings { UsersCollectionName = "users" }), NullLogger.Instance, _fakeTimeProvider); } [Fact] public async Task CreateUserAsync_輸入有效使用者_應成功建立使用者() { // Arrange var user = new UserDocument { Username = $"testuser_{Guid.NewGuid():N}", Email = $"test_{Guid.NewGuid():N}@example.com", Profile = new UserProfile { FirstName = "Test", LastName = "User", Bio = "Test user bio" } }; // Act var result = await _mongoUserService.CreateUserAsync(user); // Assert result.Should().NotBeNull(); result.Username.Should().Be(user.Username); result.Email.Should().Be(user.Email); result.Id.Should().NotBeEmpty(); result.CreatedAt.Should().Be(_fakeTimeProvider.GetUtcNow().DateTime); } [Fact] public async Task GetUserByIdAsync_輸入存在的ID_應回傳正確使用者() { // Arrange var user = new UserDocument { Username = $"gettest_{Guid.NewGuid():N}", Email = $"gettest_{Guid.NewGuid():N}@example.com", Profile = new UserProfile { FirstName = "Get", LastName = "Test" } }; var createdUser = await _mongoUserService.CreateUserAsync(user); // Act var result = await _mongoUserService.GetUserByIdAsync(createdUser.Id); // Assert result.Should().NotBeNull(); result!.Username.Should().Be(user.Username); result.Email.Should().Be(user.Email); } [Fact] public async Task UpdateUserAsync_使用樂觀鎖定_應成功更新版本號() { // Arrange var user = new UserDocument { Username = $"updatetest_{Guid.NewGuid():N}", Email = $"updatetest_{Guid.NewGuid():N}@example.com" }; var createdUser = await _mongoUserService.CreateUserAsync(user); createdUser.Profile.Bio = "Updated bio"; // Act var result = await _mongoUserService.UpdateUserAsync(createdUser); // Assert result.Should().NotBeNull(); result!.Version.Should().Be(2); result.Profile.Bio.Should().Be("Updated bio"); } [Fact] public async Task DeleteUserAsync_輸入存在的ID_應成功刪除使用者() { // Arrange var user = new UserDocument { Username = $"deletetest_{Guid.NewGuid():N}", Email = $"deletetest_{Guid.NewGuid():N}@example.com" }; var createdUser = await _mongoUserService.CreateUserAsync(user); // Act var result = await _mongoUserService.DeleteUserAsync(createdUser.Id); // Assert result.Should().BeTrue(); var deletedUser = await _mongoUserService.GetUserByIdAsync(createdUser.Id); deletedUser.Should().BeNull(); } } ``` ### MongoDB 索引測試 ```csharp using MongoDB.Driver; using AwesomeAssertions; using System.Diagnostics; namespace YourProject.Integration.Tests.MongoDB; [Collection("MongoDb Collection")] public class MongoIndexTests { private readonly IMongoCollection _users; private readonly ITestOutputHelper _output; public MongoIndexTests(MongoDbContainerFixture fixture, ITestOutputHelper output) { _users = fixture.Database.GetCollection("index_test_users"); _output = output; } [Fact] public async Task CreateUniqueIndex_電子郵件唯一索引_應防止重複插入() { // Arrange - 確保集合為空 await _users.DeleteManyAsync(FilterDefinition.Empty); // 建立唯一索引 var indexKeysDefinition = Builders.IndexKeys.Ascending(u => u.Email); var indexOptions = new CreateIndexOptions { Unique = true }; await _users.Indexes.CreateOneAsync( new CreateIndexModel(indexKeysDefinition, indexOptions)); var uniqueEmail = $"unique_{Guid.NewGuid():N}@example.com"; var user1 = new UserDocument { Username = "user1", Email = uniqueEmail }; var user2 = new UserDocument { Username = "user2", Email = uniqueEmail }; // Act & Assert await _users.InsertOneAsync(user1); // 第一次插入成功 var exception = await Assert.ThrowsAsync( () => _users.InsertOneAsync(user2)); exception.WriteError.Category.Should().Be(ServerErrorCategory.DuplicateKey); _output.WriteLine("唯一索引測試通過 - 重複的 email 被正確阻擋"); } [Fact] public async Task CompoundIndex_複合索引查詢效能_應提升查詢速度() { // Arrange - 確保集合為空 await _users.DeleteManyAsync(FilterDefinition.Empty); // 插入測試資料 var testUsers = Enumerable.Range(0, 1000) .Select(i => new UserDocument { Username = $"user_{i:D4}", Email = $"user{i:D4}_{Guid.NewGuid():N}@example.com", IsActive = i % 2 == 0, CreatedAt = DateTime.UtcNow.AddDays(-i % 365) }) .ToList(); await _users.InsertManyAsync(testUsers); // 建立複合索引 var compoundIndex = Builders.IndexKeys .Ascending(u => u.IsActive) .Descending(u => u.CreatedAt); await _users.Indexes.CreateOneAsync(new CreateIndexModel(compoundIndex)); // 測試查詢效能 var filter = Builders.Filter.And( Builders.Filter.Eq(u => u.IsActive, true), Builders.Filter.Gte(u => u.CreatedAt, DateTime.UtcNow.AddDays(-100)) ); var stopwatch = Stopwatch.StartNew(); var results = await _users.Find(filter).ToListAsync(); stopwatch.Stop(); _output.WriteLine($"查詢時間: {stopwatch.ElapsedMilliseconds}ms, 結果數量: {results.Count}"); // Assert results.Should().NotBeEmpty(); } } ``` --- ## Redis 容器化測試 ### Redis Container Fixture ```csharp using StackExchange.Redis; using Testcontainers.Redis; namespace YourProject.Integration.Tests.Fixtures; /// /// Redis 容器 Fixture - 管理 Redis 容器生命週期 /// public class RedisContainerFixture : IAsyncLifetime { private RedisContainer? _container; public IConnectionMultiplexer Connection { get; private set; } = null!; public IDatabase Database { get; private set; } = null!; public string ConnectionString { get; private set; } = string.Empty; public async Task InitializeAsync() { // 使用 Redis 7.2 版本 _container = new RedisBuilder() .WithImage("redis:7.2") .WithPortBinding(6379, true) .Build(); await _container.StartAsync(); ConnectionString = _container.GetConnectionString(); Connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString); Database = Connection.GetDatabase(); } public async Task DisposeAsync() { if (Connection != null) { await Connection.DisposeAsync(); } if (_container != null) { await _container.DisposeAsync(); } } /// /// 清空資料庫 - 使用 KeyDelete 而非 FLUSHDB(避免權限問題) /// public async Task ClearDatabaseAsync() { var server = Connection.GetServer(Connection.GetEndPoints().First()); var keys = server.Keys(Database.Database); if (keys.Any()) { await Database.KeyDeleteAsync(keys.ToArray()); } } } [CollectionDefinition("Redis Collection")] public class RedisCollectionFixture : ICollectionFixture { } ``` ### Redis 快取模型 ```csharp using System.Text.Json.Serialization; namespace YourProject.Core.Models.Redis; /// /// 通用快取包裝器 - 提供豐富的快取元資料 /// public class CacheItem { [JsonPropertyName("data")] public T Data { get; set; } = default!; [JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; } [JsonPropertyName("expires_at")] public DateTime? ExpiresAt { get; set; } [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; [JsonPropertyName("tags")] public List Tags { get; set; } = new(); [JsonPropertyName("access_count")] public int AccessCount { get; set; } [JsonPropertyName("version")] public int Version { get; set; } = 1; [JsonIgnore] public bool IsExpired => ExpiresAt.HasValue && DateTime.UtcNow > ExpiresAt.Value; [JsonIgnore] public double TtlSeconds => ExpiresAt.HasValue ? Math.Max(0, ExpiresAt.Value.Subtract(DateTime.UtcNow).TotalSeconds) : -1; public static CacheItem Create(string key, T data, TimeSpan? ttl = null, params string[] tags) { return new CacheItem { Key = key, Data = data, CreatedAt = DateTime.UtcNow, ExpiresAt = ttl.HasValue ? DateTime.UtcNow.Add(ttl.Value) : null, Tags = tags.ToList() }; } } /// /// 使用者 Session - Hash 結構範例 /// public class UserSession { public string UserId { get; set; } = string.Empty; public string SessionId { get; set; } = string.Empty; public string IpAddress { get; set; } = string.Empty; public string UserAgent { get; set; } = string.Empty; public bool IsActive { get; set; } } /// /// 最近瀏覽紀錄 - List 結構範例 /// public class RecentView { public string ItemId { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; } /// /// 排行榜項目 - Sorted Set 結構範例 /// public class LeaderboardEntry { public string UserId { get; set; } = string.Empty; public string Username { get; set; } = string.Empty; public double Score { get; set; } } ``` ### Redis 五種資料結構測試 ```csharp using StackExchange.Redis; using AwesomeAssertions; namespace YourProject.Integration.Tests.Redis; [Collection("Redis Collection")] public class RedisCacheServiceTests { private readonly RedisCacheService _redisCacheService; private readonly RedisContainerFixture _fixture; public RedisCacheServiceTests(RedisContainerFixture fixture) { _fixture = fixture; _redisCacheService = new RedisCacheService( fixture.Connection, Options.Create(new RedisSettings()), NullLogger.Instance, TimeProvider.System); } #region String 測試 [Fact] public async Task SetStringAsync_輸入字串值_應成功設定快取() { // Arrange var key = $"test_string_{Guid.NewGuid():N}"; var value = "test_string_value"; // Act var result = await _redisCacheService.SetStringAsync(key, value); // Assert result.Should().BeTrue(); var retrieved = await _redisCacheService.GetStringAsync(key); retrieved.Should().Be(value); } [Fact] public async Task SetObjectCacheAsync_輸入物件_應成功序列化並快取() { // Arrange var key = $"object_test_{Guid.NewGuid():N}"; var user = new UserDocument { Username = "objecttest", Email = "object@test.com", Profile = new UserProfile { FirstName = "Object", LastName = "Test" } }; // Act var result = await _redisCacheService.SetStringAsync(key, user, TimeSpan.FromMinutes(30)); // Assert result.Should().BeTrue(); var retrieved = await _redisCacheService.GetStringAsync(key); retrieved.Should().NotBeNull(); retrieved!.Username.Should().Be("objecttest"); } [Fact] public async Task SetMultipleStringAsync_輸入多個鍵值對_應成功批次設定() { // Arrange var prefix = Guid.NewGuid().ToString("N")[..8]; var keyValues = new Dictionary { { $"multi1_{prefix}", "value1" }, { $"multi2_{prefix}", "value2" }, { $"multi3_{prefix}", "value3" } }; // Act var result = await _redisCacheService.SetMultipleStringAsync(keyValues); // Assert result.Should().BeTrue(); foreach (var kvp in keyValues) { var value = await _redisCacheService.GetStringAsync(kvp.Key); value.Should().Be(kvp.Value); } } #endregion #region Hash 測試 [Fact] public async Task SetHashAsync_輸入字串值_應設定Hash欄位() { // Arrange var key = $"hash_test_{Guid.NewGuid():N}"; var field = "test_field"; var value = "test_value"; // Act var result = await _redisCacheService.SetHashAsync(key, field, value, TimeSpan.FromMinutes(30)); // Assert result.Should().BeTrue(); var retrieved = await _redisCacheService.GetHashAsync(key, field); retrieved.Should().Be(value); } [Fact] public async Task SetHashAllAsync_輸入物件_應設定完整Hash() { // Arrange var key = $"hash_all_{Guid.NewGuid():N}"; var session = new UserSession { UserId = "user123", SessionId = "session456", IpAddress = "192.168.1.1", UserAgent = "Test Browser", IsActive = true }; // Act var result = await _redisCacheService.SetHashAllAsync(key, session, TimeSpan.FromHours(1)); // Assert result.Should().BeTrue(); var retrieved = await _redisCacheService.GetHashAllAsync(key); retrieved.Should().NotBeNull(); retrieved!.UserId.Should().Be("user123"); retrieved.SessionId.Should().Be("session456"); } #endregion #region List 測試 [Fact] public async Task ListLeftPushAsync_輸入值_應新增到List左側() { // Arrange var key = $"list_test_{Guid.NewGuid():N}"; var view1 = new RecentView { ItemId = "item1", ItemType = "product", Title = "Product 1" }; var view2 = new RecentView { ItemId = "item2", ItemType = "product", Title = "Product 2" }; // Act var count1 = await _redisCacheService.ListLeftPushAsync(key, view1); var count2 = await _redisCacheService.ListLeftPushAsync(key, view2); // Assert count1.Should().Be(1); count2.Should().Be(2); var views = await _redisCacheService.ListRangeAsync(key); views.Should().HaveCount(2); views[0].ItemId.Should().Be("item2"); // 最後加入的在最前面 views[1].ItemId.Should().Be("item1"); } #endregion #region Set 測試 [Fact] public async Task SetAddAsync_輸入值_應新增到Set() { // Arrange var key = $"set_test_{Guid.NewGuid():N}"; var tag1 = "programming"; var tag2 = "testing"; var tag3 = "programming"; // 重複 // Act var result1 = await _redisCacheService.SetAddAsync(key, tag1); var result2 = await _redisCacheService.SetAddAsync(key, tag2); var result3 = await _redisCacheService.SetAddAsync(key, tag3); // Assert result1.Should().BeTrue(); result2.Should().BeTrue(); result3.Should().BeFalse(); // 重複項目回傳 false var tags = await _redisCacheService.SetMembersAsync(key); tags.Should().HaveCount(2); tags.Should().Contain("programming"); tags.Should().Contain("testing"); } #endregion #region Sorted Set 測試 [Fact] public async Task SortedSetAddAsync_輸入分數和成員_應成功新增到排序集合() { // Arrange var key = $"sorted_set_{Guid.NewGuid():N}"; var entry1 = new LeaderboardEntry { UserId = "user1", Username = "Player1", Score = 100 }; var entry2 = new LeaderboardEntry { UserId = "user2", Username = "Player2", Score = 200 }; // Act var result1 = await _redisCacheService.SortedSetAddAsync(key, entry1, entry1.Score); var result2 = await _redisCacheService.SortedSetAddAsync(key, entry2, entry2.Score); // Assert result1.Should().BeTrue(); result2.Should().BeTrue(); var rankings = await _redisCacheService.SortedSetRangeWithScoresAsync( key, 0, -1, Order.Descending); rankings.Should().HaveCount(2); rankings[0].Member.Username.Should().Be("Player2"); // 分數高的在前面 rankings[0].Score.Should().Be(200); } #endregion #region TTL 與過期測試 [Fact] public async Task ExpireAsync_輸入過期時間_應正確設定TTL() { // Arrange var key = $"expire_test_{Guid.NewGuid():N}"; await _redisCacheService.SetStringAsync(key, "expire_value"); // Act var result = await _redisCacheService.ExpireAsync(key, TimeSpan.FromMinutes(5)); // Assert result.Should().BeTrue(); var ttl = await _redisCacheService.GetTtlAsync(key); ttl.Should().NotBeNull(); ttl!.Value.TotalMinutes.Should().BeGreaterThan(4); } #endregion #region 資料隔離測試 [Fact] public async Task 測試資料隔離_多個測試同時執行_應不互相影響() { // Arrange var testId = Guid.NewGuid().ToString("N")[..8]; var key1 = $"isolation_test:{testId}:key1"; var key2 = $"isolation_test:{testId}:key2"; // Act await _redisCacheService.SetStringAsync(key1, "value1"); await _redisCacheService.SetStringAsync(key2, "value2"); // Assert var value1 = await _redisCacheService.GetStringAsync(key1); var value2 = await _redisCacheService.GetStringAsync(key2); value1.Should().Be("value1"); value2.Should().Be("value2"); // Cleanup await _redisCacheService.DeleteAsync(key1); await _redisCacheService.DeleteAsync(key2); } #endregion } ``` --- ## 最佳實踐 ### 1. Collection Fixture 模式 使用 Collection Fixture 共享容器,避免每個測試重啟容器: ```csharp // 定義集合 [CollectionDefinition("MongoDb Collection")] public class MongoDbCollectionFixture : ICollectionFixture { } // 使用集合 [Collection("MongoDb Collection")] public class MyMongoTests { public MyMongoTests(MongoDbContainerFixture fixture) { // 使用共享的容器 } } ``` ### 2. 資料隔離策略 確保測試間不互相干擾: ```csharp // MongoDB:使用唯一的 Email/Username var user = new UserDocument { Username = $"testuser_{Guid.NewGuid():N}", Email = $"test_{Guid.NewGuid():N}@example.com" }; // Redis:使用唯一的 Key 前綴 var testId = Guid.NewGuid().ToString("N")[..8]; var key = $"test:{testId}:mykey"; ``` ### 3. 清理策略 ```csharp // MongoDB:測試後清理 await fixture.ClearDatabaseAsync(); // Redis:使用 KeyDelete 而非 FLUSHDB(避免權限問題) var keys = server.Keys(database.Database); if (keys.Any()) { await database.KeyDeleteAsync(keys.ToArray()); } ``` ### 4. 效能考量 | 策略 | 說明 | | ------------------ | -------------------------------------------- | | Collection Fixture | 容器只啟動一次,節省 80%+ 時間 | | 資料隔離 | 使用唯一 Key/ID 而非清空資料庫 | | 批次操作 | 使用 InsertManyAsync、SetMultipleStringAsync | | 索引建立 | 在 Fixture 初始化時建立索引 | --- ## 常見問題 ### Redis FLUSHDB 權限問題 某些 Redis 容器映像檔預設不啟用 admin 模式: ```csharp // ❌ 錯誤:可能失敗 await server.FlushDatabaseAsync(); // ✅ 正確:使用 KeyDelete var keys = server.Keys(database.Database); if (keys.Any()) { await database.KeyDeleteAsync(keys.ToArray()); } ``` ### MongoDB 唯一索引重複插入 ```csharp // 測試時使用唯一的 Email 避免衝突 var uniqueEmail = $"test_{Guid.NewGuid():N}@example.com"; ``` ### 容器啟動超時 ```csharp // 增加等待時間 _container = new MongoDbBuilder() .WithImage("mongo:7.0") .WithWaitStrategy(Wait.ForUnixContainer() .UntilPortIsAvailable(27017)) .Build(); ``` --- ## 相關技能 - [testcontainers-database](../testcontainers-database/SKILL.md) - PostgreSQL/MSSQL 容器化測試 - [aspnet-integration-testing](../aspnet-integration-testing/SKILL.md) - ASP.NET Core 整合測試 - [nsubstitute-mocking](../../dotnet-testing/nsubstitute-mocking/SKILL.md) - 測試替身與 Mock --- ## 參考資源 ### 原始文章 本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章: - **Day 22 - Testcontainers 整合測試:MongoDB 及 Redis 基礎到進階** - 鐵人賽文章:https://ithelp.ithome.com.tw/articles/10376740 - 範例程式碼:https://github.com/kevintsengtw/30Days_in_Testing_Samples/tree/main/day22 ### 官方文件 - [Testcontainers 官方網站](https://testcontainers.com/) - [.NET Testcontainers 文件](https://dotnet.testcontainers.org/) - [MongoDB.Driver 官方文件](https://www.mongodb.com/docs/drivers/csharp/) - [StackExchange.Redis 官方文件](https://stackexchange.github.io/StackExchange.Redis/) - [xUnit Collection Fixtures](https://xunit.net/docs/shared-context#collection-fixture)