Testing like a PRO with XUnit & Localstack - .NET 7
Transcript
Ever wondered how to get Docker images to run nicely with xUnit tests? Then this is the right video for you. Let me show you how to set up LocalStack with xUnit properly so that the only thing you need to worry about is having Docker running.
We will first set up the base infrastructure using two lesser known xUnit features before doing container management using Testcontainers. The first feature of xUnit that we're going to take a look at is lifecycle management with IAsyncLifetime. We're going to use this to create an instance of a LocalStack container later on, but we first need to set up a class initially so that we can use it with our second xUnit feature.
When we add the IAsyncLifetime interface to our class, we tell xUnit that we need to start and dispose of this implementation in an asynchronous manner. This means we need to implement two different methods: InitializeAsync and DisposeAsync.
The next feature of xUnit that we're going to use is collection fixtures. This allows us to have a singleton instance of our object for all tests, unlike a class fixture. A class fixture will only allow us to have a singleton instance for any test class that inherits from a class fixture. The reason I'm opting to use a collection fixture is so that we've reduced the number of instances of LocalStack because it can be quite heavy to run. If you think you can get away with a regular class fixture for your project, you can definitely do that.
A collection fixture needs three different parts. We need to create a class for the collection definition, we need to add an attribute to our test class pointing to that collection definition, before finally injecting the class instance into the test class itself.
So let's take a look at creating a class for our collection definition. This is going to be an empty or marker class because xUnit needs this for discovery purposes. We first need to inherit from ICollectionFixture of T, where T is a type that you want to singleton and that will be injected to our test class. Next we need to add a class level attribute called CollectionDefinition which requires us to supply a name. I'm just going to use nameof(T) to point back to the class that we created.
So let's move back to our test class. I have already pre-populated this with a couple of random tests which will ensure everything is working later on. To make use of the collection definition, we're going to need to add a class level attribute to our test class called Collection. This takes a single parameter. For this Collection attribute we need to pass in the same name that we gave to the CollectionDefinition attribute earlier, so here we're just going to use the same nameof expression so that everything lines up nicely for us.
The last bit is to create a new constructor and add a parameter for our type T, which for us is going to be our LocalStack container. So at this point we have xUnit all configured to use a single instance of our container class, and that container class has an asynchronous lifetime for us.
So now let's look at how we would set up LocalStack. If you haven't heard of Testcontainers before, you should go and check the project out. A link for this is in the description below. We're going to use Testcontainers to spin up a new instance of LocalStack in our tests using the container class that we created earlier.
First of all, let's create two properties. One for the LocalStack port that we're going to use, and the next for a generated URL using the new port number just to make our lives a little bit easier later on. Then we need to create a private field to hold our container instance so we can manipulate it from initialize and dispose.
So let's set up the container instance in the constructor. We need to create a new instance of TestcontainersBuilder with the type TestcontainersContainer, from which we can configure this container. We need to call a few different methods on this instance. The first is WithImage, this tells us which Docker image to use. WithCleanUp tells Testcontainers to clean up the image when we're done with it. WithPortBinding maps ports for us. It's important that we use the generated port number here, mapping back to the consistent LocalStack port 4566.
Once we've called these methods, we can call Build on the container definition and then we can go and manipulate it inside of our lifecycle events. In the InitializeAsync method, we first need to create a new CancellationTokenSource so we can abort any slow startups should we need to. Then we call StartAsync on the container instance we set up earlier, remembering to pass in the cancellation token. Inside of the DisposeAsync method, we just need to call DisposeAsync on the Testcontainers instance.
With this now done, we're in a suitable point where we can test the entire flow and watch our Docker containers spin up and tear down without any other effort than clicking run on the tests.
So now that we have a container up and running, we want the ability to seed it with something useful, such as data for DynamoDB. In order to do this, we do need to make a few changes to our container configuration. First we're going to create a couple of directories where we're going to hold our initialization data. The first directory is called aws-c-data. This will hold an initialization script that we will use to ensure the ordering of our scripts. That script will call into our second directory which we're going to call scripts. This is where the bulk of your scripts are going to go. It's important to know that any .sh scripts that you create must have the line feed setting.
So under the aws-c-data folder we first need to create an init.
sh script. Inside of this script we're just going to have a simple line that says bash /scripts/dynamodb.sh. Note that the forward slash is very important as we will bind this later on.
In the scripts subdirectory, let's create a new script called dynamodb.sh. In here we're going to create a new DynamoDB table using the awslocal command. This command is essentially a fully fledged AWS CLI but you never have to set the endpoint, which makes it super handy for scripting. In my example here I'm just creating a DynamoDB table as I would do normally, I'm just switching out aws for awslocal. Although I'm not doing it here, you can do a lot of other fancy things like seeding data into the DynamoDB table.
Now that we have our scripts set up, let's head back to the LocalStack container instance where we need to adjust the setup to mount our seeding files. To do this, we're going to need the full path on our host machine where we're going to want to mount from, and the corresponding destination inside the Docker image. LocalStack has a special folder inside the image which is scanned to look for scripts at different parts of the initialization process. This folder is /etc/localstack/init and it contains four different directories: boot.
d, which runs when the container is running. start.d is when the Python process is running. ready.d is when LocalStack is ready to serve requests. And finally shutdown.d, which is when LocalStack is shutting down.
For our scripts to work, we need to make sure that LocalStack is actually ready, so we're going to mount the folder containing our init.sh script to the directory ready.d. This means our init script will be called as soon as LocalStack is ready for it to be executed. To do this, we're going to call WithBindMount on the container image, passing in our folder followed by the folder that we want to mount to inside of the Docker image.
Because our init script calls out to a different directory, we're also going to need to map our scripts directory. So again we need to call WithBindMount and map our scripts directory to /scripts.
All of our scripts are now ready to run inside of the instance, but we do have a slight ordering problem. Our tests may be executed before our LocalStack instance has been fully provisioned. Lucky for us, we have a couple of features inside of Testcontainers and LocalStack that have this scenario covered.
LocalStack has an endpoint to check the status of the initialization. This is located on the HTTP endpoint _localstack/init. Testcontainers allow us to provide our own wait strategy too. So we can use both of these two features combined to create a new wait check implementation inside of Testcontainers.
So let's create a new class and make it implement the IWaitUntil interface. In the constructor of the wait check, we need to take a single parameter which is going to be the endpoint of LocalStack. We then need to complete the Until method. We will use the Until method to make a call out to the API. We don't need to worry about retry strategies here, we just need to return a true or false from this method and Testcontainers will continually call this on a regular interval until it succeeds.
The structure of the JSON returned from the LocalStack init endpoint has a completed object which has keys for each of the initialization phases. It also has a scripts section which contains a list of scripts for each stage and their corresponding states. So we just need to check the scripts section and look for the init.
sh script inside the ready stage and ensure that it has the state "OK". This means our init script has run to completion.
The last bit for us to do is to configure the wait strategy for the container. Back to our container definition, we need to call a method called WithWaitStrategy. This takes one or more wait strategies which are built using the Wait class. The first thing we need to do on this Wait class is to tell Testcontainers that we're going to be waiting for a Linux container. We do this by calling Wait.ForUnixContainer(), and then we can call two additional methods.
The first is UntilPortIsAvailable, which will check the Linux container to ensure that a port is being listened on. This will always need to be the port inside of the container that's being listened to, not the port that we're going to be calling on. So in our case, it is always going to be LocalStack's port, 4566.
Next we call AddCustomWaitStrategy, and in here we just pass in a new instance of our wait strategy, passing in the LocalStack URL.
With all of these steps completed, you should now have tests that are nice and repeatable using xUnit and LocalStack. You can also repeat the same methodology for things like MySQL.
If you enjoyed this video, consider subscribing to the YouTube channel for more content like this.