*A* way to use Docker for integration tests

This approach was partially stolen from an approach I found in the Akka.Net code for integration testing. The Sql Server bits are taken from a blog post from David Neal. Lastly, I started down this path for integration testing inside the Jasper codebase, but have since switched to just using a Docker compose file in that case. 

Integration testing with databases kind of sucks. I know it, you know it, the American people know it. It’s also really valuable to do if you can pull it off, so here we are. What are some of the problems with testing databases? Let’s see:

  • It’s more effective when you have isolated databases per developer and environment
  • You should really have some kind of build automation that syncs your test database up with the current version of your schema
  • Ideally, you’d really like it to be as easy as possible for a developer to do a clean clone of your codebase and quickly have integration tests up and running without a lot of teeth gnashing over setting up either a local database or getting their own schema somewhere on a shared database server

With all that in mind, my current client project is trying to get away with running Sql Server in a Docker container that’s spun up on demand by the Docker.DotNet library within the integration tests.

The first step is a little base class helper I originally wrote (and discarded) to define a Docker container instance:

    internal abstract class DockerServer
    {
        public string ImageName { get; }
        public string ContainerName { get; }

        protected DockerServer(string imageName, string containerName)
        {
            ImageName = imageName;
            ContainerName = containerName; // + "-" + Guid.NewGuid().ToString();
        }

        public async Task Start(IDockerClient client)
        {
            if (StartAction != StartAction.none) return;


            var images =
                await client.Images.ListImagesAsync(new ImagesListParameters { MatchName = ImageName });


            if (images.Count == 0)
            {
                Console.WriteLine($"Fetching Docker image '{ImageName}'");

                await client.Images.CreateImageAsync(
                    new ImagesCreateParameters { FromImage = ImageName, Tag = "latest" }, null,
                    new Progress());
            }

            var list = await client.Containers.ListContainersAsync(new ContainersListParameters
            {
                All = true
            });

            var container = list.FirstOrDefault(x => x.Names.Contains("/" + ContainerName));
            if (container == null)
            {
                await createContainer(client);

            }
            else
            {
                if (container.State == "running")
                {
                    Console.WriteLine($"Container '{ContainerName}' is already running.");
                    StartAction = StartAction.external;
                    return;
                }
            }


            var started = await client.Containers.StartContainerAsync(ContainerName, new ContainerStartParameters());
            if (!started)
            {
                throw new InvalidOperationException($"Container '{ContainerName}' did not start!!!!");
            }

            var i = 0;
            while (!await isReady())
            {
                i++;

                if (i > 20)
                {
                    throw new TimeoutException($"Container {ContainerName} does not seem to be responding in a timely manner");
                }

                await Task.Delay(5.Seconds());
            }

            Console.WriteLine($"Container '{ContainerName}' is ready.");

            StartAction = StartAction.started;
        }

        public StartAction StartAction { get; private set; } = StartAction.none;

        private async Task createContainer(IDockerClient client)
        {
            Console.WriteLine($"Creating container '{ContainerName}' using image '{ImageName}'");

            var hostConfig = ToHostConfig();
            var config = ToConfig();

            await client.Containers.CreateContainerAsync(new CreateContainerParameters(config)
            {
                Image = ImageName,
                Name = ContainerName,
                Tty = true,
                HostConfig = hostConfig,
            });
        }

        public async Task Stop(IDockerClient client)
        {
            await client.Containers.StopContainerAsync(ContainerName, new ContainerStopParameters());
        }

        public Task Remove(IDockerClient client)
        {
            return client.Containers.RemoveContainerAsync(ContainerName,
                new ContainerRemoveParameters { Force = true });
        }

        protected abstract Task isReady();

        public abstract HostConfig ToHostConfig();

        public abstract Config ToConfig();

        public override string ToString()
        {
            return $"{nameof(ImageName)}: {ImageName}, {nameof(ContainerName)}: {ContainerName}";
        }
    }

I know, it’s a bit long, but in essence it just gives you the ability to define a simple Docker container you want to be running during tests and enough smarts to download missing images, start new containers on your box as necessary, and wait around until that Docker container is really ready.

To extend that approach to Sql Server, we use this class (partially elided):

    internal class SqlServerContainer : DockerServer
    {
        public SqlServerContainer() : base("microsoft/mssql-server-linux:latest", "dev-mssql")
        {
        }

        public static readonly string ConnectionString = "Server=127.0.0.1,1434;User Id=sa;Password=P@55w0rd;Timeout=5";

        // Gotta wait until the database is really available
        // or you'll get oddball test failures;)
        protected override async Task isReady()
        {
            try
            {
                using (var conn =
                    new SqlConnection(ConnectionString))
                {
                    await conn.OpenAsync();

                    return true;
                }
            }
            catch (Exception)
            {
                return false;
            }
        }

        // Watch the port mapping here to avoid port
        // contention w/ other Sql Server installations
        public override HostConfig ToHostConfig()
        {
            return new HostConfig
            {
                PortBindings = new Dictionary<string, IList>
                {
                    {
                        "1433/tcp",
                        new List
                        {
                            new PortBinding
                            {
                                HostPort = $"1434",
                                HostIP = "127.0.0.1"
                            }
                        }
                    },


                },

            };
        }

        public override Config ToConfig()
        {
            return new Config
            {
                Env = new List { "ACCEPT_EULA=Y", "SA_PASSWORD=P@55w0rd", "MSSQL_PID=Developer" }
            };
        }

        public static void RebuildSchema()
        {
            DropSchema();
            InitializeSchema();
        }

        public static void DropSchema()
        {
            // drops the configured schema from the database
        }

        public static void InitializeSchema()
        {
            // rebuilds the schema objects
        }
    }

Now, in tests we take advantage of the IClassFixture mechanism in xUnit.Net to ensure that our Sql Server container is spun up and executing before any integration test runs. First, add a class that just manages the lifetime of the Docker container that will be the shared context in integration tests:

    public class IntegrationFixture
    {
        private readonly IDockerClient _client;
        private readonly SqlServerContainer _container;
        
        public IntegrationFixture()
        {
            _client = DockerServers.BuildDockerClient();
            _container = new SqlServerContainer();

            _container.Start(_client).Wait(300.Seconds());


            SqlServerContainer.RebuildSchema();
        }
    }

With that in place, integration tests in our codebase need to implement IClassFixture<IntegrationFixture> and take that in with a constructor function like this:

    public class an_integration_test_class : IClassFixture<IntegrationFixture>
    {
        public an_integration_test_class(IntegrationFixture fixture)
        {
        }
        
        // Add facts
    }

If this is really common, you might slap together a base class for your integration tests that automatically implements the IClassFixture<IntegrationFixture> interface so you don’t have to think about that every time.

It’s too early to say this is a huge success, but so far, so good. We’ve had some friction around our CI build process with the Docker usage.

Do note that I purposely avoided shutting down the Docker container for Sql Server in any kind of testing teardown. I do that so that developers are able to interrogate the state of the database after tests to help troubleshoot inevitable testing failures.

Why this over using a Docker Compose file that would need to be executed before running any of the tests? I’m not sure I’d give you any hard and fast opinion on when or when not to do that, but in this case it’s helpful for Visual Studio.Net-centric devs to just be able to run tests from VS.Net without having to think about any other additional set up. I did choose to use Docker Compose files in Jasper where I had four different containers, and a handful of tests that need more than one of those containers.

Advertisement

5 thoughts on “*A* way to use Docker for integration tests

  1. Am I understanding correctly that this will create a new container for each test class, rather than for each test collection? How do you find performance doing this?

  2. By no means, this creates a container *if one does not already exist* the first time any test is executed that depends on that xUnit fixture. So after doing a clean clone, the first test executed will be a potentially huge hit, but other than that, not so bad

    1. Hah, you threw me a bit using `IClassFixture` instead of `ICollectionFixture`, so wanted to be sure 🙂

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s