skills/kevintsengtw/dotnet-testing-agent-skills/dotnet-testing-advanced-testcontainers-nosql

dotnet-testing-advanced-testcontainers-nosql

SKILL.md

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 容器
  • 一致性測試:測試結果直接反映正式環境行為
  • 隔離性保證:每個測試環境完全獨立
  • 效能驗證:可進行真實的索引效能測試

環境需求

必要套件

<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();

相關技能


參考資源

原始文章

本技能內容提煉自「老派軟體工程師的測試修練 - 30 天挑戰」系列文章:

官方文件

Weekly Installs
6
Installed on
claude-code5
antigravity4
gemini-cli4
windsurf3
opencode3
codex3