diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 00000000..1e614b19
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,22 @@
+name: CI
+
+on: [push]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Build
+ run: dotnet build src
+
+ - name: Redis Tests
+ run: dotnet test src --filter Name~Redis
+
\ No newline at end of file
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index a7d2f366..01d076f4 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -22,6 +22,7 @@
+
diff --git a/src/DistributedLock.Tests/DistributedLock.Tests.csproj b/src/DistributedLock.Tests/DistributedLock.Tests.csproj
index be82219e..6710cce8 100644
--- a/src/DistributedLock.Tests/DistributedLock.Tests.csproj
+++ b/src/DistributedLock.Tests/DistributedLock.Tests.csproj
@@ -27,6 +27,7 @@
+
diff --git a/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs b/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs
index d524205a..3d2a32ea 100644
--- a/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs
+++ b/src/DistributedLock.Tests/Infrastructure/Redis/RedisServer.cs
@@ -1,5 +1,5 @@
-using Medallion.Shell;
-using StackExchange.Redis;
+using StackExchange.Redis;
+using Testcontainers.Redis;
namespace Medallion.Threading.Tests.Redis;
@@ -8,33 +8,29 @@ internal class RedisServer
// redis default is 6379, so go one above that
private static readonly int MinDynamicPort = RedisPorts.DefaultPorts.Max() + 1, MaxDynamicPort = MinDynamicPort + 100;
- // it's important for this to be lazy because it doesn't work when running on Linux
- private static readonly Lazy WslPath = new(
- () => Directory.GetDirectories(@"C:\Windows\WinSxS")
- .Select(d => Path.Combine(d, "wsl.exe"))
- .Where(File.Exists)
- .OrderByDescending(File.GetCreationTimeUtc)
- .First()
- );
+ public static async Task DisposeAsync()
+ {
+ foreach (var container in RedisContainers)
+ {
+ await container.StopAsync();
+ }
+ }
- private static readonly Dictionary ActiveServersByPort = [];
+ private static readonly List RedisContainers = [];
private static readonly RedisServer[] DefaultServers = new RedisServer[RedisPorts.DefaultPorts.Count];
- private readonly Command _command;
+ private readonly RedisContainer _redis;
- public RedisServer(bool allowAdmin = false) : this(null, allowAdmin) { }
-
- private RedisServer(int? port, bool allowAdmin)
+ public RedisServer(bool allowAdmin = false)
{
- lock (ActiveServersByPort)
- {
- this.Port = port ?? Enumerable.Range(MinDynamicPort, count: MaxDynamicPort - MinDynamicPort + 1)
- .First(p => !ActiveServersByPort.ContainsKey(p));
- this._command = Command.Run(WslPath.Value, ["redis-server", "--port", this.Port], options: o => o.StartInfo(si => si.RedirectStandardInput = false))
- .RedirectTo(Console.Out)
- .RedirectStandardErrorTo(Console.Error);
- ActiveServersByPort.Add(this.Port, this);
- }
+ _redis = new RedisBuilder()
+ .WithPortBinding(MinDynamicPort + RedisContainers.Count)
+ .Build();
+ _redis.StartAsync().Wait();
+ RedisContainers.Add(_redis);
+
+ this.Port = _redis.GetMappedPublicPort(RedisBuilder.RedisPort);
+
this.Multiplexer = ConnectionMultiplexer.Connect($"localhost:{this.Port},abortConnect=false{(allowAdmin ? ",allowAdmin=true" : string.Empty)}");
// Clean the db to ensure it is empty. Running an arbitrary command also ensures that
// the db successfully spun up before we proceed (Connect seemingly can complete before that happens).
@@ -43,49 +39,16 @@ private RedisServer(int? port, bool allowAdmin)
this.Multiplexer.GetDatabase().Execute("flushall", Array.Empty