Migrating Redis Tests: From Mock To Testcontainers

by Alex Johnson 51 views

This article discusses the migration of Redis tests from a mock setup to Testcontainers, highlighting the benefits, implementation steps, and overall impact on test reliability and CI performance. The original approach used mocks to simulate Redis operations, but this proved unreliable due to the complexity of StackExchange.Redis and the limitations of mocking async operations and key expiration logic. By transitioning to Testcontainers, we leverage real Redis instances for integration tests, ensuring accurate behavior and consistent results.

The Problem: Unreliable Redis Tests with Mocking

The core issue stems from the inherent limitations of mocking Redis behavior. Our initial approach involved using Mock<IDatabase> (StackExchange.Redis) to simulate Redis interactions in our unit tests. However, this method proved inadequate, leading to flaky tests and unreliable results. Specifically, seven unit tests within RedisOAuthStateStoreTests were failing intermittently. These failures highlighted the difficulty in accurately simulating async Redis operations, key expiration, and atomic operations using mocks.

The failing tests included:

  1. StoreStateAsync_ValidState_StoresInRedisWithTTL
  2. StoreStateAsync_NullState_ThrowsArgumentException
  3. StoreStateAsync_EmptyState_ThrowsArgumentException
  4. ValidateAndRemoveStateAsync_ValidState_ReturnsTrue
  5. ValidateAndRemoveStateAsync_ExpiredState_ReturnsFalse
  6. ValidateAndRemoveStateAsync_InvalidState_ReturnsFalse
  7. Constructor_WithNullRedis_ThrowsArgumentNullException

The root cause of these failures lies in the complexities of StackExchange.Redis and the challenges of mocking its internal state management. The original code utilized a mocking framework to simulate Redis interactions, which introduced several limitations. Async operations, which are crucial for Redis's performance, require precise timing and synchronization simulation that mocks struggle to replicate accurately. Key expiration (TTL) logic, a core feature of Redis, cannot be reliably mocked, leading to tests that pass or fail inconsistently. Furthermore, Lua script execution, which enables atomic operations in Redis, is virtually impossible to test effectively using mocks. The initial setup looked like this:

// ❌ CURRENT APPROACH - Mock StackExchange.Redis (unreliable)
private readonly Mock<IConnectionMultiplexer> _mockRedis;
private readonly Mock<IDatabase> _mockDatabase;

public RedisOAuthStateStoreTests()
{
    _mockRedis = new Mock<IConnectionMultiplexer>();
    _mockDatabase = new Mock<IDatabase>();

    _mockRedis.Setup(r => r.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
        .Returns(_mockDatabase.Object);

    // Mock setup is complex and doesn't match real Redis behavior
    _mockDatabase.Setup(db => db.StringSetAsync(...)).ReturnsAsync(true);
}

The Solution: Embrace Testcontainers for Reliable Integration Tests

The solution to these challenges is to migrate our Redis tests to Testcontainers. Testcontainers is a library that allows us to run Docker containers within our tests, providing a real, isolated Redis instance for each test execution. This approach eliminates the need for mocks and ensures that our tests interact with Redis in a way that closely mirrors production behavior. By leveraging Testcontainers, we can confidently validate the functionality of our Redis-dependent code and ensure its reliability in real-world scenarios. Testcontainers provide a lightweight and efficient way to spin up and manage containers as part of our testing process. This allows us to create a consistent and reproducible testing environment, ensuring that our tests are not affected by external factors.

Key Benefits of Using Testcontainers

  • Accurate Behavior: By using a real Redis instance, we guarantee that our tests reflect production-like operations, leading to more reliable and meaningful results.
  • TTL Testing: Testcontainers enable accurate testing of key expiration logic, a crucial aspect of Redis functionality, ensuring that our data management processes work as expected.
  • Atomic Operations: With Testcontainers, we can effectively test Lua scripts and other atomic operations, validating the integrity of our data manipulation processes.
  • Reliability: The complexity and fragility of mock setups are eliminated, resulting in a more robust and dependable testing environment.
  • Consistency: Adopting Testcontainers aligns our Redis tests with the established patterns used for PostgreSQL and Qdrant tests, promoting a consistent and maintainable testing strategy across our project.

Implementation: Step-by-Step Migration to Testcontainers

The migration process involves several key steps to ensure a smooth transition from mocks to Testcontainers. These steps include adding necessary dependencies, implementing the IAsyncLifetime pattern for container management, updating test attributes, and verifying the new setup in both local and CI environments.

Step 1: Add Testcontainers Dependencies

First, we need to add the required Testcontainers NuGet packages to our project. In our case, these dependencies were already present in the Api.Tests.csproj file, ensuring that we have the necessary libraries for working with Testcontainers and Redis containers.

<PackageReference Include="Testcontainers" Version="4.3.0" />
<PackageReference Include="Testcontainers.Redis" Version="4.3.0" />

Step 2: Implement the IAsyncLifetime Pattern

The IAsyncLifetime interface provides a structured way to manage the lifecycle of our Testcontainers. By implementing this interface, we can ensure that the Redis container is started before our tests run and stopped after they complete. This pattern helps maintain a clean and isolated testing environment.

using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using StackExchange.Redis;
using Xunit;

namespace Api.Tests.Infrastructure;

/// <summary>
/// Integration tests for RedisOAuthStateStore using real Redis container.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Dependency", "Redis")]
public class RedisOAuthStateStoreTests : IAsyncLifetime
{
    private IContainer? _redisContainer;
    private IConnectionMultiplexer? _redis;
    private RedisOAuthStateStore? _stateStore;
    private ILogger<RedisOAuthStateStore>? _logger;

    public async Task InitializeAsync()
    {
        // Start Redis container
        _redisContainer = new ContainerBuilder()
            .WithImage("redis:7-alpine")
            .WithPortBinding(6379, true)
            .WithWaitStrategy(Wait.ForUnixContainer()
                .UntilCommandIsCompleted("redis-cli", "ping"))
            .Build();

        await _redisContainer.StartAsync();

        // Connect to Redis
        var port = _redisContainer.GetMappedPublicPort(6379);
        var connectionString = {{content}}quot;localhost:{port}";
        _redis = await ConnectionMultiplexer.ConnectAsync(connectionString);

        // Create service under test
        _logger = new Mock<ILogger<RedisOAuthStateStore>>().Object;
        _stateStore = new RedisOAuthStateStore(_redis, _logger);
    }

    public async Task DisposeAsync()
    {
        _redis?.Dispose();
        if (_redisContainer != null)
            await _redisContainer.DisposeAsync();
    }

    [Fact]
    public async Task StoreStateAsync_ValidState_StoresInRedisWithTTL()
    {
        // Arrange
        var state = "test-state-12345";
        var expiration = TimeSpan.FromSeconds(5);

        // Act
        await _stateStore!.StoreStateAsync(state, expiration);

        // Assert - Verify key exists in real Redis
        var db = _redis!.GetDatabase();
        var exists = await db.KeyExistsAsync({{content}}quot;meepleai:oauth:state:{state}");
        Assert.True(exists);

        // Verify TTL is set correctly
        var ttl = await db.KeyTimeToLiveAsync({{content}}quot;meepleai:oauth:state:{state}");
        Assert.NotNull(ttl);
        Assert.InRange(ttl.Value.TotalSeconds, 3, 5); // Allow 2s margin
    }

    [Fact]
    public async Task StoreStateAsync_KeyExpires_AfterTTL()
    {
        // Arrange
        var state = "expiring-state";
        var expiration = TimeSpan.FromSeconds(2);

        // Act
        await _stateStore!.StoreStateAsync(state, expiration);

        // Assert - Key exists initially
        var db = _redis!.GetDatabase();
        var exists = await db.KeyExistsAsync({{content}}quot;meepleai:oauth:state:{state}");
        Assert.True(exists);

        // Wait for expiration
        await Task.Delay(TimeSpan.FromSeconds(3));

        // Verify key is gone
        exists = await db.KeyExistsAsync({{content}}quot;meepleai:oauth:state:{state}");
        Assert.False(exists);
    }

    [Fact]
    public async Task ValidateAndRemoveStateAsync_ValidState_ReturnsTrue()
    {
        // Arrange
        var state = "valid-state";
        await _stateStore!.StoreStateAsync(state, TimeSpan.FromMinutes(10));

        // Act
        var result = await _stateStore.ValidateAndRemoveStateAsync(state);

        // Assert
        Assert.True(result);

        // Verify key is removed (single-use)
        var db = _redis!.GetDatabase();
        var exists = await db.KeyExistsAsync({{content}}quot;meepleai:oauth:state:{state}");
        Assert.False(exists);
    }

    [Fact]
    public async Task ValidateAndRemoveStateAsync_InvalidState_ReturnsFalse()
    {
        // Arrange
        var state = "non-existent-state";

        // Act
        var result = await _stateStore!.ValidateAndRemoveStateAsync(state);

        // Assert
        Assert.False(result);
    }

    [Fact]
    public async Task ValidateAndRemoveStateAsync_ExpiredState_ReturnsFalse()
    {
        // Arrange
        var state = "expired-state";
        await _stateStore!.StoreStateAsync(state, TimeSpan.FromSeconds(1));
        await Task.Delay(TimeSpan.FromSeconds(2)); // Wait for expiration

        // Act
        var result = await _stateStore.ValidateAndRemoveStateAsync(state);

        // Assert
        Assert.False(result);
    }

    [Fact]
    public async Task StoreStateAsync_NullState_ThrowsArgumentException()
    {
        // Arrange
        var expiration = TimeSpan.FromMinutes(10);

        // Act & Assert
        await Assert.ThrowsAsync<ArgumentException>(() =>
            _stateStore!.StoreStateAsync(null!, expiration));
    }

    [Fact]
    public async Task StoreStateAsync_EmptyState_ThrowsArgumentException()
    {
        // Arrange
        var expiration = TimeSpan.FromMinutes(10);

        // Act & Assert
        await Assert.ThrowsAsync<ArgumentException>(() =>
            _stateStore!.StoreStateAsync("", expiration));
    }

    [Fact]
    public void Constructor_WithNullRedis_ThrowsArgumentNullException()
    {
        // Arrange
        var logger = new Mock<ILogger<RedisOAuthStateStore>>().Object;

        // Act & Assert
        Assert.Throws<ArgumentNullException>(() =>
            new RedisOAuthStateStore(null!, logger));
    }
}

The code snippet above demonstrates the implementation of the IAsyncLifetime pattern within our RedisOAuthStateStoreTests class. The InitializeAsync method is responsible for starting the Redis container and establishing a connection to it. The DisposeAsync method ensures that the container is properly stopped and disposed of after the tests have completed. This setup guarantees that each test runs against a fresh Redis instance, preventing interference between tests and ensuring consistent results.

Step 3: Update Test Attributes

To properly categorize our tests and ensure they are included in integration test runs, we update the test attributes. Adding the `[Trait(