Claude-skill-registry dotnet-testing-advanced-testcontainers-nosql
install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/dotnet-testing-advanced-testcontainers-nosql" ~/.claude/skills/majiayu000-claude-skill-registry-dotnet-testing-advanced-testcontainers-nosql && rm -rf "$T"
manifest:
skills/data/dotnet-testing-advanced-testcontainers-nosql/SKILL.mdsource content
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 資料庫測試與關聯式資料庫有顯著差異:
- 文件模型複雜度:MongoDB 支援巢狀物件、陣列、字典等複雜結構
- 無固定 Schema:需要透過測試驗證資料結構的一致性
- 多樣化資料結構:Redis 有五種主要資料結構,各有不同使用場景
- 序列化處理:BSON (MongoDB) 與 JSON (Redis) 序列化行為需要驗證
Testcontainers 優勢
- 真實環境模擬:使用實際的 MongoDB 7.0 和 Redis 7.2 容器
- 一致性測試:測試結果直接反映正式環境行為
- 隔離性保證:每個測試環境完全獨立
- 效能驗證:可進行真實的索引效能測試
環境需求
必要套件
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net9.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <IsPackable>false</IsPackable> </PropertyGroup> <ItemGroup> <!-- MongoDB 相關套件 --> <PackageReference Include="MongoDB.Driver" Version="3.0.0" /> <PackageReference Include="MongoDB.Bson" Version="3.0.0" /> <!-- Redis 相關套件 --> <PackageReference Include="StackExchange.Redis" Version="2.8.16" /> <!-- Testcontainers --> <PackageReference Include="Testcontainers" Version="4.0.0" /> <PackageReference Include="Testcontainers.MongoDb" Version="4.0.0" /> <PackageReference Include="Testcontainers.Redis" Version="4.0.0" /> <!-- 測試框架 --> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="AwesomeAssertions" Version="9.1.0" /> <!-- JSON 序列化與時間測試 --> <PackageReference Include="System.Text.Json" Version="9.0.0" /> <PackageReference Include="Microsoft.Bcl.TimeProvider" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.0.0" /> </ItemGroup> </Project>
套件版本說明
| 套件 | 版本 | 用途 |
|---|---|---|
| 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% 以上的測試時間:
using MongoDB.Driver; using Testcontainers.MongoDb; namespace YourProject.Integration.Tests.Fixtures; /// <summary> /// MongoDB 容器 Fixture - 實作 IAsyncLifetime 管理容器生命週期 /// </summary> 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(); } } /// <summary> /// 清空資料庫中的所有集合 - 用於測試間隔離 /// </summary> public async Task ClearDatabaseAsync() { var collections = await Database.ListCollectionNamesAsync(); await collections.ForEachAsync(async collectionName => { await Database.DropCollectionAsync(collectionName); }); } } /// <summary> /// 定義使用 MongoDB Fixture 的測試集合 /// </summary> [CollectionDefinition("MongoDb Collection")] public class MongoDbCollectionFixture : ICollectionFixture<MongoDbContainerFixture> { // 此類別不需要實作,僅用於標記集合 }
MongoDB 文件模型設計
建立包含巢狀物件、陣列、字典等複雜結構的文件模型:
using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; namespace YourProject.Core.Models.Mongo; /// <summary> /// 使用者文件 - 展示 MongoDB 複雜文件結構 /// </summary> 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<Address> Addresses { get; set; } = new(); [BsonElement("skills")] public List<Skill> Skills { get; set; } = new(); [BsonElement("preferences")] public Dictionary<string, object> 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; /// <summary> /// 樂觀鎖定版本遞增 /// </summary> public void IncrementVersion(DateTime updateTime) { Version++; UpdatedAt = updateTime; } } /// <summary> /// 使用者檔案 - 巢狀文件範例 /// </summary> 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<string, string> SocialLinks { get; set; } = new(); [BsonIgnore] public string FullName => $"{FirstName} {LastName}".Trim(); } /// <summary> /// 地址模型 - 用於地理空間查詢 /// </summary> 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; } } /// <summary> /// 地理位置 - GeoJSON 格式 /// </summary> 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 } }; } } /// <summary> /// 技能模型 - 陣列查詢範例 /// </summary> 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; } } /// <summary> /// 技能等級列舉 /// </summary> public enum SkillLevel { [BsonRepresentation(BsonType.String)] Beginner, [BsonRepresentation(BsonType.String)] Intermediate, [BsonRepresentation(BsonType.String)] Advanced, [BsonRepresentation(BsonType.String)] Expert }
BSON 序列化測試
驗證 BSON 序列化行為:
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 測試
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<MongoUserService>.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 索引測試
using MongoDB.Driver; using AwesomeAssertions; using System.Diagnostics; namespace YourProject.Integration.Tests.MongoDB; [Collection("MongoDb Collection")] public class MongoIndexTests { private readonly IMongoCollection<UserDocument> _users; private readonly ITestOutputHelper _output; public MongoIndexTests(MongoDbContainerFixture fixture, ITestOutputHelper output) { _users = fixture.Database.GetCollection<UserDocument>("index_test_users"); _output = output; } [Fact] public async Task CreateUniqueIndex_電子郵件唯一索引_應防止重複插入() { // Arrange - 確保集合為空 await _users.DeleteManyAsync(FilterDefinition<UserDocument>.Empty); // 建立唯一索引 var indexKeysDefinition = Builders<UserDocument>.IndexKeys.Ascending(u => u.Email); var indexOptions = new CreateIndexOptions { Unique = true }; await _users.Indexes.CreateOneAsync( new CreateIndexModel<UserDocument>(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<MongoWriteException>( () => _users.InsertOneAsync(user2)); exception.WriteError.Category.Should().Be(ServerErrorCategory.DuplicateKey); _output.WriteLine("唯一索引測試通過 - 重複的 email 被正確阻擋"); } [Fact] public async Task CompoundIndex_複合索引查詢效能_應提升查詢速度() { // Arrange - 確保集合為空 await _users.DeleteManyAsync(FilterDefinition<UserDocument>.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<UserDocument>.IndexKeys .Ascending(u => u.IsActive) .Descending(u => u.CreatedAt); await _users.Indexes.CreateOneAsync(new CreateIndexModel<UserDocument>(compoundIndex)); // 測試查詢效能 var filter = Builders<UserDocument>.Filter.And( Builders<UserDocument>.Filter.Eq(u => u.IsActive, true), Builders<UserDocument>.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
using StackExchange.Redis; using Testcontainers.Redis; namespace YourProject.Integration.Tests.Fixtures; /// <summary> /// Redis 容器 Fixture - 管理 Redis 容器生命週期 /// </summary> 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(); } } /// <summary> /// 清空資料庫 - 使用 KeyDelete 而非 FLUSHDB(避免權限問題) /// </summary> 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<RedisContainerFixture> { }
Redis 快取模型
using System.Text.Json.Serialization; namespace YourProject.Core.Models.Redis; /// <summary> /// 通用快取包裝器 - 提供豐富的快取元資料 /// </summary> public class CacheItem<T> { [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<string> 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<T> Create(string key, T data, TimeSpan? ttl = null, params string[] tags) { return new CacheItem<T> { Key = key, Data = data, CreatedAt = DateTime.UtcNow, ExpiresAt = ttl.HasValue ? DateTime.UtcNow.Add(ttl.Value) : null, Tags = tags.ToList() }; } } /// <summary> /// 使用者 Session - Hash 結構範例 /// </summary> 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; } } /// <summary> /// 最近瀏覽紀錄 - List 結構範例 /// </summary> public class RecentView { public string ItemId { get; set; } = string.Empty; public string ItemType { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; } /// <summary> /// 排行榜項目 - Sorted Set 結構範例 /// </summary> public class LeaderboardEntry { public string UserId { get; set; } = string.Empty; public string Username { get; set; } = string.Empty; public double Score { get; set; } }
Redis 五種資料結構測試
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<RedisCacheService>.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<string>(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<UserDocument>(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<string, string> { { $"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<string>(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<string>(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<UserSession>(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<RecentView>(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<string>(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<LeaderboardEntry>( 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<string>(key1); var value2 = await _redisCacheService.GetStringAsync<string>(key2); value1.Should().Be("value1"); value2.Should().Be("value2"); // Cleanup await _redisCacheService.DeleteAsync(key1); await _redisCacheService.DeleteAsync(key2); } #endregion }
最佳實踐
1. Collection Fixture 模式
使用 Collection Fixture 共享容器,避免每個測試重啟容器:
// 定義集合 [CollectionDefinition("MongoDb Collection")] public class MongoDbCollectionFixture : ICollectionFixture<MongoDbContainerFixture> { } // 使用集合 [Collection("MongoDb Collection")] public class MyMongoTests { public MyMongoTests(MongoDbContainerFixture fixture) { // 使用共享的容器 } }
2. 資料隔離策略
確保測試間不互相干擾:
// 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. 清理策略
// 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 模式:
// ❌ 錯誤:可能失敗 await server.FlushDatabaseAsync(); // ✅ 正確:使用 KeyDelete var keys = server.Keys(database.Database); if (keys.Any()) { await database.KeyDeleteAsync(keys.ToArray()); }
MongoDB 唯一索引重複插入
// 測試時使用唯一的 Email 避免衝突 var uniqueEmail = $"test_{Guid.NewGuid():N}@example.com";
容器啟動超時
// 增加等待時間 _container = new MongoDbBuilder() .WithImage("mongo:7.0") .WithWaitStrategy(Wait.ForUnixContainer() .UntilPortIsAvailable(27017)) .Build();
相關技能
- testcontainers-database - PostgreSQL/MSSQL 容器化測試
- aspnet-integration-testing - ASP.NET Core 整合測試
- nsubstitute-mocking - 測試替身與 Mock
參考資源
原始文章
本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:
- Day 22 - Testcontainers 整合測試:MongoDB 及 Redis 基礎到進階